Implement search provider (#201)

* Begin work on search provider

* Initial search provider work, organize meson

* Initial work on icons

* Implement LaunchSearch

* Don't hold arbitrary reference to service

I don't know why Lollypop does this

* Send notification, pad images

* Update translations

* Fix init_search_term typing
This commit is contained in:
kramo
2023-10-10 22:47:32 +02:00
committed by GitHub
parent 61e7e0274c
commit 69928a8b4f
52 changed files with 687 additions and 293 deletions

View File

@@ -0,0 +1 @@
def _(_msg: str, /) -> str: ...

58
cartridges/cartridges.in Executable file
View File

@@ -0,0 +1,58 @@
#!@PYTHON@
# cartridges.in
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import gettext
import locale
import os
import signal
import sys
VERSION = "@VERSION@"
if os.name == "nt":
from ctypes import windll
os.environ["LANGUAGE"] = locale.windows_locale[
windll.kernel32.GetUserDefaultUILanguage()
]
PKGDATADIR = os.path.join(os.path.dirname(__file__), "..", "share", "cartridges")
LOCALEDIR = os.path.join(os.path.dirname(__file__), "..", "share", "locale")
else:
PKGDATADIR = "@pkgdatadir@"
LOCALEDIR = "@localedir@"
sys.path.insert(1, PKGDATADIR)
signal.signal(signal.SIGINT, signal.SIG_DFL)
if os.name != "nt":
locale.bindtextdomain("cartridges", LOCALEDIR)
locale.textdomain("cartridges")
gettext.install("cartridges", LOCALEDIR)
if __name__ == "__main__":
from gi.repository import Gio
resource = Gio.Resource.load(os.path.join(PKGDATADIR, "cartridges.gresource"))
resource._register() # pylint: disable=protected-access
from cartridges import main
sys.exit(main.main(VERSION))

View File

@@ -0,0 +1,331 @@
# details_window.py
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
import shlex
from pathlib import Path
from time import time
from typing import Any, Optional
from gi.repository import Adw, Gio, GLib, Gtk
from PIL import Image, UnidentifiedImageError
from cartridges import shared
from cartridges.errors.friendly_error import FriendlyError
from cartridges.game import Game
from cartridges.game_cover import GameCover
from cartridges.store.managers.cover_manager import CoverManager
from cartridges.store.managers.sgdb_manager import SgdbManager
from cartridges.utils.create_dialog import create_dialog
from cartridges.utils.save_cover import convert_cover, save_cover
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/details-window.ui")
class DetailsWindow(Adw.Window):
__gtype_name__ = "DetailsWindow"
cover_overlay = Gtk.Template.Child()
cover = Gtk.Template.Child()
cover_button_edit = Gtk.Template.Child()
cover_button_delete_revealer = Gtk.Template.Child()
cover_button_delete = Gtk.Template.Child()
spinner = Gtk.Template.Child()
name = Gtk.Template.Child()
developer = Gtk.Template.Child()
executable = Gtk.Template.Child()
exec_info_label = Gtk.Template.Child()
exec_info_popover = Gtk.Template.Child()
file_chooser_button = Gtk.Template.Child()
apply_button = Gtk.Template.Child()
cover_changed: bool = False
def __init__(self, game: Optional[Game] = None, **kwargs: Any):
super().__init__(**kwargs)
self.game: Game = game
self.game_cover: GameCover = GameCover({self.cover})
self.set_transient_for(shared.win)
if self.game:
self.set_title(_("Game Details"))
self.name.set_text(self.game.name)
if self.game.developer:
self.developer.set_text(self.game.developer)
self.executable.set_text(self.game.executable)
self.apply_button.set_label(_("Apply"))
self.game_cover.new_cover(self.game.get_cover_path())
if self.game_cover.get_texture():
self.cover_button_delete_revealer.set_reveal_child(True)
else:
self.set_title(_("Add New Game"))
self.apply_button.set_label(_("Add"))
image_filter = Gtk.FileFilter(name=_("Images"))
for extension in Image.registered_extensions():
image_filter.add_suffix(extension[1:])
image_filter.add_suffix("svg") # Gdk.Texture supports .svg but PIL doesn't
image_filters = Gio.ListStore.new(Gtk.FileFilter)
image_filters.append(image_filter)
exec_filter = Gtk.FileFilter(name=_("Executables"))
exec_filter.add_mime_type("application/x-executable")
exec_filters = Gio.ListStore.new(Gtk.FileFilter)
exec_filters.append(exec_filter)
self.image_file_dialog = Gtk.FileDialog()
self.image_file_dialog.set_filters(image_filters)
self.image_file_dialog.set_default_filter(image_filter)
self.exec_file_dialog = Gtk.FileDialog()
self.exec_file_dialog.set_filters(exec_filters)
self.exec_file_dialog.set_default_filter(exec_filter)
# Translate this string as you would translate "file"
file_name = _("file.txt")
# As in software
exe_name = _("program")
if os.name == "nt":
exe_name += ".exe"
# Translate this string as you would translate "path to {}"
exe_path = _("C:\\path\\to\\{}").format(exe_name)
# Translate this string as you would translate "path to {}"
file_path = _("C:\\path\\to\\{}").format(file_name)
command = "start"
else:
# Translate this string as you would translate "path to {}"
exe_path = _("/path/to/{}").format(exe_name)
# Translate this string as you would translate "path to {}"
file_path = _("/path/to/{}").format(file_name)
command = "xdg-open"
# pylint: disable=line-too-long
exec_info_text = _(
'To launch the executable "{}", use the command:\n\n<tt>"{}"</tt>\n\nTo open the file "{}" with the default application, use:\n\n<tt>{} "{}"</tt>\n\nIf the path contains spaces, make sure to wrap it in double quotes!'
).format(exe_name, exe_path, file_name, command, file_path)
self.exec_info_label.set_label(exec_info_text)
self.exec_info_popover.update_property(
(Gtk.AccessibleProperty.LABEL,),
(
exec_info_text.replace("<tt>", "").replace("</tt>", ""),
), # Remove formatting, else the screen reader reads it
)
def set_exec_info_a11y_label(*_args: Any) -> None:
self.set_focus(self.exec_info_popover)
self.exec_info_popover.connect("show", set_exec_info_a11y_label)
self.cover_button_delete.connect("clicked", self.delete_pixbuf)
self.cover_button_edit.connect("clicked", self.choose_cover)
self.file_chooser_button.connect("clicked", self.choose_executable)
self.apply_button.connect("clicked", self.apply_preferences)
self.name.connect("entry-activated", self.focus_executable)
self.developer.connect("entry-activated", self.focus_executable)
self.executable.connect("entry-activated", self.apply_preferences)
self.set_focus(self.name)
self.present()
def delete_pixbuf(self, *_args: Any) -> None:
self.game_cover.new_cover()
self.cover_button_delete_revealer.set_reveal_child(False)
self.cover_changed = True
def apply_preferences(self, *_args: Any) -> None:
final_name = self.name.get_text()
final_developer = self.developer.get_text()
final_executable = self.executable.get_text()
if not self.game:
if final_name == "":
create_dialog(
self, _("Couldn't Add Game"), _("Game title cannot be empty.")
)
return
if final_executable == "":
create_dialog(
self, _("Couldn't Add Game"), _("Executable cannot be empty.")
)
return
# Increment the number after the game id (eg. imported_1, imported_2)
source_id = "imported"
numbers = [0]
game_id: str
for game_id in shared.store.source_games.get(source_id, set()):
prefix = "imported_"
if not game_id.startswith(prefix):
continue
numbers.append(int(game_id.replace(prefix, "", 1)))
game_number = max(numbers) + 1
self.game = Game(
{
"game_id": f"imported_{game_number}",
"hidden": False,
"source": source_id,
"added": int(time()),
}
)
if shared.win.sidebar.get_selected_row().get_child() not in (
shared.win.all_games_row_box,
shared.win.added_row_box,
):
shared.win.sidebar.select_row(shared.win.added_row_box.get_parent())
else:
if final_name == "":
create_dialog(
self,
_("Couldn't Apply Preferences"),
_("Game title cannot be empty."),
)
return
if final_executable == "":
create_dialog(
self,
_("Couldn't Apply Preferences"),
_("Executable cannot be empty."),
)
return
self.game.name = final_name
self.game.developer = final_developer or None
self.game.executable = final_executable
if self.game.game_id in shared.win.game_covers.keys():
shared.win.game_covers[self.game.game_id].animation = None
shared.win.game_covers[self.game.game_id] = self.game_cover
if self.cover_changed:
save_cover(
self.game.game_id,
self.game_cover.path,
)
shared.store.add_game(self.game, {}, run_pipeline=False)
self.game.save()
self.game.update()
# TODO: this is fucked up (less than before)
# Get a cover from SGDB if none is present
if not self.game_cover.get_texture():
self.game.set_loading(1)
sgdb_manager = shared.store.managers[SgdbManager]
sgdb_manager.reset_cancellable()
sgdb_manager.process_game(self.game, {}, self.update_cover_callback)
self.game_cover.pictures.remove(self.cover)
self.close()
shared.win.show_details_page(self.game)
def update_cover_callback(self, manager: SgdbManager) -> None:
# Set the game as not loading
self.game.set_loading(-1)
self.game.update()
# Handle errors that occured
for error in manager.collect_errors():
# On auth error, inform the user
if isinstance(error, FriendlyError):
create_dialog(
shared.win,
error.title,
error.subtitle,
"open_preferences",
_("Preferences"),
).connect("response", self.update_cover_error_response)
def update_cover_error_response(self, _widget: Any, response: str) -> None:
if response == "open_preferences":
shared.win.get_application().on_preferences_action(page_name="sgdb")
def focus_executable(self, *_args: Any) -> None:
self.set_focus(self.executable)
def toggle_loading(self) -> None:
self.apply_button.set_sensitive(not self.apply_button.get_sensitive())
self.spinner.set_spinning(not self.spinner.get_spinning())
self.cover_overlay.set_opacity(not self.cover_overlay.get_opacity())
def set_cover(self, _source: Any, result: Gio.Task, *_args: Any) -> None:
try:
path = self.image_file_dialog.open_finish(result).get_path()
except GLib.GError:
return
def thread_func() -> None:
new_path = None
try:
with Image.open(path) as image:
if getattr(image, "is_animated", False):
new_path = convert_cover(path)
except UnidentifiedImageError:
pass
if not new_path:
new_path = convert_cover(
pixbuf=shared.store.managers[CoverManager].composite_cover(
Path(path)
)
)
if new_path:
self.game_cover.new_cover(new_path)
self.cover_button_delete_revealer.set_reveal_child(True)
self.cover_changed = True
self.toggle_loading()
self.toggle_loading()
GLib.Thread.new(None, thread_func)
def set_executable(self, _source: Any, result: Gio.Task, *_args: Any) -> None:
try:
path = self.exec_file_dialog.open_finish(result).get_path()
except GLib.GError:
return
self.executable.set_text(shlex.quote(path))
def choose_executable(self, *_args: Any) -> None:
self.exec_file_dialog.open(self, None, self.set_executable)
def choose_cover(self, *_args: Any) -> None:
self.image_file_dialog.open(self, None, self.set_cover)

View File

@@ -0,0 +1,28 @@
from threading import Lock
class ErrorProducer:
"""
A mixin for objects that produce errors.
Specifies the report_error and collect_errors methods in a thread-safe manner.
"""
errors: list[Exception]
errors_lock: Lock
def __init__(self) -> None:
self.errors = []
self.errors_lock = Lock()
def report_error(self, error: Exception) -> None:
"""Report an error"""
with self.errors_lock:
self.errors.append(error)
def collect_errors(self) -> list[Exception]:
"""Collect and remove the errors produced by the object"""
with self.errors_lock:
errors = self.errors.copy()
self.errors.clear()
return errors

View File

@@ -0,0 +1,47 @@
from typing import Iterable, Optional
class FriendlyError(Exception):
"""
An error that is supposed to be shown to the user in a nice format
Use `raise ... from ...` to preserve context.
"""
title_format: str
title_args: Iterable[str]
subtitle_format: str
subtitle_args: Iterable[str]
@property
def title(self) -> str:
"""Get the gettext translated error title"""
return self.title_format.format(self.title_args)
@property
def subtitle(self) -> str:
"""Get the gettext translated error subtitle"""
return self.subtitle_format.format(self.subtitle_args)
def __init__(
self,
title: str,
subtitle: str,
title_args: Optional[Iterable[str]] = None,
subtitle_args: Optional[Iterable[str]] = None,
) -> None:
"""Create a friendly error
:param str title: The error's title, translatable with gettext
:param str subtitle: The error's subtitle, translatable with gettext
"""
super().__init__()
if title is not None:
self.title_format = title
if subtitle is not None:
self.subtitle_format = subtitle
self.title_args = title_args if title_args else ()
self.subtitle_args = subtitle_args if subtitle_args else ()
def __str__(self) -> str:
return f"{self.title} - {self.subtitle}"

203
cartridges/game.py Normal file
View File

@@ -0,0 +1,203 @@
# game.py
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import shlex
from pathlib import Path
from time import time
from typing import Any, Optional
from gi.repository import Adw, GObject, Gtk
from cartridges import shared
from cartridges.game_cover import GameCover
from cartridges.utils.run_executable import run_executable
# pylint: disable=too-many-instance-attributes
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/game.ui")
class Game(Gtk.Box):
__gtype_name__ = "Game"
title = Gtk.Template.Child()
play_button = Gtk.Template.Child()
cover = Gtk.Template.Child()
spinner = Gtk.Template.Child()
cover_button = Gtk.Template.Child()
menu_button = Gtk.Template.Child()
play_revealer = Gtk.Template.Child()
menu_revealer = Gtk.Template.Child()
game_options = Gtk.Template.Child()
hidden_game_options = Gtk.Template.Child()
loading: int = 0
filtered: bool = False
added: int
executable: str
game_id: str
source: str
hidden: bool = False
last_played: int = 0
name: str
developer: Optional[str] = None
removed: bool = False
blacklisted: bool = False
game_cover: GameCover = None
version: int = 0
def __init__(self, data: dict[str, Any], **kwargs: Any) -> None:
super().__init__(**kwargs)
self.app = shared.win.get_application()
self.version = shared.SPEC_VERSION
self.update_values(data)
self.base_source = self.source.split("_")[0]
self.set_play_icon()
self.event_contoller_motion = Gtk.EventControllerMotion.new()
self.add_controller(self.event_contoller_motion)
self.event_contoller_motion.connect("enter", self.toggle_play, False)
self.event_contoller_motion.connect("leave", self.toggle_play, None, None)
self.cover_button.connect("clicked", self.main_button_clicked, False)
self.play_button.connect("clicked", self.main_button_clicked, True)
shared.schema.connect("changed", self.schema_changed)
def update_values(self, data: dict[str, Any]) -> None:
for key, value in data.items():
# Convert executables to strings
if key == "executable" and isinstance(value, list):
value = shlex.join(value)
setattr(self, key, value)
def update(self) -> None:
self.emit("update-ready", {})
def save(self) -> None:
self.emit("save-ready", {})
def create_toast(self, title: str, action: Optional[str] = None) -> None:
toast = Adw.Toast.new(title.format(self.name))
toast.set_priority(Adw.ToastPriority.HIGH)
toast.set_use_markup(False)
if action:
toast.set_button_label(_("Undo"))
toast.connect("button-clicked", shared.win.on_undo_action, self, action)
if (self, action) in shared.win.toasts.keys():
# Dismiss the toast if there already is one
shared.win.toasts[(self, action)].dismiss()
shared.win.toasts[(self, action)] = toast
shared.win.toast_overlay.add_toast(toast)
def launch(self) -> None:
self.last_played = int(time())
self.save()
self.update()
run_executable(self.executable)
if shared.schema.get_boolean("exit-after-launch"):
self.app.quit()
# The variable is the title of the game
self.create_toast(_("{} launched"))
def toggle_hidden(self, toast: bool = True) -> None:
self.hidden = not self.hidden
self.save()
if shared.win.navigation_view.get_visible_page() == shared.win.details_page:
shared.win.navigation_view.pop()
self.update()
if toast:
self.create_toast(
# The variable is the title of the game
(_("{} hidden") if self.hidden else _("{} unhidden")).format(self.name),
"hide",
)
def remove_game(self) -> None:
# Add "removed=True" to the game properties so it can be deleted on next init
self.removed = True
self.save()
self.update()
if shared.win.navigation_view.get_visible_page() == shared.win.details_page:
shared.win.navigation_view.pop()
# The variable is the title of the game
self.create_toast(_("{} removed").format(self.name), "remove")
def set_loading(self, state: int) -> None:
self.loading += state
loading = self.loading > 0
self.cover.set_opacity(int(not loading))
self.spinner.set_spinning(loading)
def get_cover_path(self) -> Optional[Path]:
cover_path = shared.covers_dir / f"{self.game_id}.gif"
if cover_path.is_file():
return cover_path # type: ignore
cover_path = shared.covers_dir / f"{self.game_id}.tiff"
if cover_path.is_file():
return cover_path # type: ignore
return None
def toggle_play(
self, _widget: Any, _prop1: Any, _prop2: Any, state: bool = True
) -> None:
if not self.menu_button.get_active():
self.play_revealer.set_reveal_child(not state)
self.menu_revealer.set_reveal_child(not state)
def main_button_clicked(self, _widget: Any, button: bool) -> None:
if shared.schema.get_boolean("cover-launches-game") ^ button:
self.launch()
else:
shared.win.show_details_page(self)
def set_play_icon(self) -> None:
self.play_button.set_icon_name(
"help-about-symbolic"
if shared.schema.get_boolean("cover-launches-game")
else "media-playback-start-symbolic"
)
def schema_changed(self, _settings: Any, key: str) -> None:
if key == "cover-launches-game":
self.set_play_icon()
@GObject.Signal(name="update-ready", arg_types=[object])
def update_ready(self, _additional_data): # type: ignore
"""Signal emitted when the game needs updating"""
@GObject.Signal(name="save-ready", arg_types=[object])
def save_ready(self, _additional_data): # type: ignore
"""Signal emitted when the game needs saving"""

132
cartridges/game_cover.py Normal file
View File

@@ -0,0 +1,132 @@
# game_cover.py
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from typing import Optional
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
from PIL import Image, ImageFilter, ImageStat
from cartridges import shared
class GameCover:
texture: Optional[Gdk.Texture]
blurred: Optional[Gdk.Texture]
luminance: Optional[tuple[float, float]]
path: Optional[Path]
animation: Optional[GdkPixbuf.PixbufAnimation]
anim_iter: Optional[GdkPixbuf.PixbufAnimationIter]
placeholder = Gdk.Texture.new_from_resource(
shared.PREFIX + "/library_placeholder.svg"
)
placeholder_small = Gdk.Texture.new_from_resource(
shared.PREFIX + "/library_placeholder_small.svg"
)
def __init__(self, pictures: set[Gtk.Picture], path: Optional[Path] = None) -> None:
self.pictures = pictures
self.new_cover(path)
def new_cover(self, path: Optional[Path] = None) -> None:
self.animation = None
self.texture = None
self.blurred = None
self.luminance = None
self.path = path
if path:
if path.suffix == ".gif":
self.animation = GdkPixbuf.PixbufAnimation.new_from_file(str(path))
self.anim_iter = self.animation.get_iter()
self.task = Gio.Task.new()
self.task.run_in_thread(
lambda *_: self.update_animation((self.task, self.animation))
)
else:
self.texture = Gdk.Texture.new_from_filename(str(path))
if not self.animation:
self.set_texture(self.texture)
def get_texture(self) -> Gdk.Texture:
return (
Gdk.Texture.new_for_pixbuf(self.animation.get_static_image())
if self.animation
else self.texture
)
def get_blurred(self) -> Gdk.Texture:
if not self.blurred:
if self.path:
with Image.open(self.path) as image:
image = (
image.convert("RGB")
.resize((100, 150))
.filter(ImageFilter.GaussianBlur(20))
)
tmp_path = Gio.File.new_tmp(None)[0].get_path()
image.save(tmp_path, "tiff", compression=None)
self.blurred = Gdk.Texture.new_from_filename(tmp_path)
stat = ImageStat.Stat(image.convert("L"))
# Luminance values for light and dark mode
self.luminance = (
min((stat.mean[0] + stat.extrema[0][0]) / 510, 0.7),
max((stat.mean[0] + stat.extrema[0][1]) / 510, 0.3),
)
else:
self.blurred = self.placeholder_small
self.luminance = (0.3, 0.5)
return self.blurred
def add_picture(self, picture: Gtk.Picture) -> None:
self.pictures.add(picture)
if not self.animation:
self.set_texture(self.texture)
else:
self.update_animation((self.task, self.animation))
def set_texture(self, texture: Gdk.Texture) -> None:
self.pictures.discard(
picture for picture in self.pictures if not picture.is_visible()
)
if not self.pictures:
self.animation = None
else:
for picture in self.pictures:
picture.set_paintable(texture or self.placeholder)
def update_animation(self, data: GdkPixbuf.PixbufAnimation) -> None:
if self.animation == data[1]:
self.anim_iter.advance() # type: ignore
self.set_texture(Gdk.Texture.new_for_pixbuf(self.anim_iter.get_pixbuf())) # type: ignore
delay_time = self.anim_iter.get_delay_time() # type: ignore
GLib.timeout_add(
20 if delay_time < 20 else delay_time,
self.update_animation,
data,
)

View File

@@ -0,0 +1,109 @@
# bottles_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from typing import NamedTuple
import yaml
from cartridges import shared
from cartridges.game import Game
from cartridges.importer.location import Location, LocationSubPath
from cartridges.importer.source import SourceIterable, URLExecutableSource
class BottlesSourceIterable(SourceIterable):
source: "BottlesSource"
def __iter__(self):
"""Generator method producing games"""
data = self.source.locations.data["library.yml"].read_text("utf-8")
library: dict = yaml.safe_load(data)
for entry in library.values():
# Build game
values = {
"source": self.source.source_id,
"added": shared.import_time,
"name": entry["name"],
"game_id": self.source.game_id_format.format(game_id=entry["id"]),
"executable": self.source.make_executable(
bottle_name=entry["bottle"]["name"],
game_name=entry["name"],
),
}
game = Game(values)
# Get official cover path
try:
# This will not work if both Cartridges and Bottles are installed via Flatpak
# as Cartridges can't access directories picked via Bottles' file picker portal
bottles_location = Path(
yaml.safe_load(
self.source.locations.data["data.yml"].read_text("utf-8")
)["custom_bottles_path"]
)
except (FileNotFoundError, KeyError):
bottles_location = self.source.locations.data.root / "bottles"
bottle_path = entry["bottle"]["path"]
additional_data = {}
if entry["thumbnail"]:
image_name = entry["thumbnail"].split(":")[1]
image_path = bottles_location / bottle_path / "grids" / image_name
additional_data = {"local_image_path": image_path}
yield (game, additional_data)
class BottlesLocations(NamedTuple):
data: Location
class BottlesSource(URLExecutableSource):
"""Generic Bottles source"""
source_id = "bottles"
name = _("Bottles")
iterable_class = BottlesSourceIterable
url_format = 'bottles:run/"{bottle_name}"/"{game_name}"'
available_on = {"linux"}
locations: BottlesLocations
def __init__(self) -> None:
super().__init__()
self.locations = BottlesLocations(
Location(
schema_key="bottles-location",
candidates=(
shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles",
shared.data_dir / "bottles/",
shared.home / ".local" / "share" / "bottles",
),
paths={
"library.yml": LocationSubPath("library.yml"),
"data.yml": LocationSubPath("data.yml"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
)

View File

@@ -0,0 +1,223 @@
# desktop_source.py
#
# Copyright 2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
import shlex
import subprocess
from pathlib import Path
from typing import NamedTuple
from gi.repository import GLib, Gtk
from cartridges import shared
from cartridges.game import Game
from cartridges.importer.source import Source, SourceIterable
class DesktopSourceIterable(SourceIterable):
source: "DesktopSource"
def __iter__(self):
"""Generator method producing games"""
icon_theme = Gtk.IconTheme.new()
search_paths = [
shared.home / ".local" / "share",
"/run/host/usr/local/share",
"/run/host/usr/share",
"/run/host/usr/share/pixmaps",
"/usr/share/pixmaps",
] + GLib.get_system_data_dirs()
for search_path in search_paths:
path = Path(search_path)
if not str(search_path).endswith("/pixmaps"):
path = path / "icons"
if not path.is_dir():
continue
if str(path).startswith("/app/"):
continue
icon_theme.add_search_path(str(path))
launch_command, full_path = self.check_launch_commands()
for path in search_paths:
if str(path).startswith("/app/"):
continue
path = Path(path) / "applications"
if not path.is_dir():
continue
for entry in path.iterdir():
if entry.suffix != ".desktop":
continue
# Skip Lutris games
if str(entry.name).startswith("net.lutris."):
continue
keyfile = GLib.KeyFile.new()
try:
keyfile.load_from_file(str(entry), 0)
if "Game" not in keyfile.get_string_list(
"Desktop Entry", "Categories"
):
continue
name = keyfile.get_string("Desktop Entry", "Name")
executable = keyfile.get_string("Desktop Entry", "Exec").split(
" %"
)[0]
except GLib.GError:
continue
try:
try_exec = "which " + keyfile.get_string("Desktop Entry", "TryExec")
if not self.check_command(try_exec):
continue
except GLib.GError:
pass
# Skip Steam games
if "steam://rungameid/" in executable:
continue
# Skip Heroic games
if "heroic://launch/" in executable:
continue
# Skip Bottles games
if "bottles-cli " in executable:
continue
try:
if keyfile.get_boolean("Desktop Entry", "NoDisplay"):
continue
except GLib.GError:
pass
try:
if keyfile.get_boolean("Desktop Entry", "Hidden"):
continue
except GLib.GError:
pass
# Strip /run/host from Flatpak paths
if entry.is_relative_to(prefix := "/run/host"):
entry = Path("/") / entry.relative_to(prefix)
launch_arg = shlex.quote(str(entry if full_path else entry.stem))
values = {
"source": self.source.source_id,
"added": shared.import_time,
"name": name,
"game_id": f"desktop_{entry.stem}",
"executable": f"{launch_command} {launch_arg}",
}
game = Game(values)
additional_data = {}
try:
icon_str = keyfile.get_string("Desktop Entry", "Icon")
except GLib.GError:
yield game
continue
else:
if "/" in icon_str:
additional_data = {"local_icon_path": Path(icon_str)}
yield (game, additional_data)
continue
try:
if (
icon_path := icon_theme.lookup_icon(
icon_str,
None,
512,
1,
shared.win.get_direction(),
0,
)
.get_file()
.get_path()
):
additional_data = {"local_icon_path": Path(icon_path)}
except GLib.GError:
pass
yield (game, additional_data)
def check_command(self, command) -> bool:
flatpak_str = "flatpak-spawn --host /bin/sh -c "
if os.getenv("FLATPAK_ID") == shared.APP_ID:
command = flatpak_str + shlex.quote(command)
try:
subprocess.run(command, shell=True, check=True)
except subprocess.CalledProcessError:
return False
return True
def check_launch_commands(self) -> (str, bool):
"""Check whether `gio launch` `gtk4-launch` or `gtk-launch` are available on the system"""
commands = (("gio launch", True), ("gtk4-launch", False), ("gtk-launch", False))
for command, full_path in commands:
# Even if `gio` is available, `gio launch` is only available on GLib >= 2.67.2
command_to_check = (
"gio help launch" if command == "gio launch" else f"which {command}"
)
if self.check_command(command_to_check):
return command, full_path
return commands[2]
class DesktopLocations(NamedTuple):
pass
class DesktopSource(Source):
"""Generic Flatpak source"""
source_id = "desktop"
name = _("Desktop Entries")
iterable_class = DesktopSourceIterable
available_on = {"linux"}
locations: DesktopLocations
def __init__(self) -> None:
super().__init__()
self.locations = DesktopLocations()

View File

@@ -0,0 +1,140 @@
# flatpak_source.py
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from typing import NamedTuple
from gi.repository import GLib, Gtk
from cartridges import shared
from cartridges.game import Game
from cartridges.importer.location import Location, LocationSubPath
from cartridges.importer.source import ExecutableFormatSource, SourceIterable
class FlatpakSourceIterable(SourceIterable):
source: "FlatpakSource"
def __iter__(self):
"""Generator method producing games"""
icon_theme = Gtk.IconTheme.new()
icon_theme.add_search_path(str(self.source.locations.data["icons"]))
blacklist = (
{"hu.kramo.Cartridges", "hu.kramo.Cartridges.Devel"}
if shared.schema.get_boolean("flatpak-import-launchers")
else {
"hu.kramo.Cartridges",
"hu.kramo.Cartridges.Devel",
"com.valvesoftware.Steam",
"net.lutris.Lutris",
"com.heroicgameslauncher.hgl",
"com.usebottles.Bottles",
"io.itch.itch",
"org.libretro.RetroArch",
}
)
for entry in (self.source.locations.data["applications"]).iterdir():
if entry.suffix != ".desktop":
continue
keyfile = GLib.KeyFile.new()
try:
keyfile.load_from_file(str(entry), 0)
if "Game" not in keyfile.get_string_list("Desktop Entry", "Categories"):
continue
if (
flatpak_id := keyfile.get_string("Desktop Entry", "X-Flatpak")
) in blacklist or flatpak_id != entry.stem:
continue
name = keyfile.get_string("Desktop Entry", "Name")
except GLib.GError:
continue
values = {
"source": self.source.source_id,
"added": shared.import_time,
"name": name,
"game_id": self.source.game_id_format.format(game_id=flatpak_id),
"executable": self.source.make_executable(flatpak_id=flatpak_id),
}
game = Game(values)
additional_data = {}
try:
if (
icon_path := icon_theme.lookup_icon(
keyfile.get_string("Desktop Entry", "Icon"),
None,
512,
1,
shared.win.get_direction(),
0,
)
.get_file()
.get_path()
):
additional_data = {"local_icon_path": Path(icon_path)}
else:
pass
except GLib.GError:
pass
yield (game, additional_data)
class FlatpakLocations(NamedTuple):
data: Location
class FlatpakSource(ExecutableFormatSource):
"""Generic Flatpak source"""
source_id = "flatpak"
name = _("Flatpak")
iterable_class = FlatpakSourceIterable
executable_format = "flatpak run {flatpak_id}"
available_on = {"linux"}
locations: FlatpakLocations
def __init__(self) -> None:
super().__init__()
self.locations = FlatpakLocations(
Location(
schema_key="flatpak-location",
candidates=(
"/var/lib/flatpak/",
shared.data_dir / "flatpak",
),
paths={
"applications": LocationSubPath("exports/share/applications", True),
"icons": LocationSubPath("exports/share/icons", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
)

View File

@@ -0,0 +1,387 @@
# heroic_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
from abc import abstractmethod
from functools import cached_property
from hashlib import sha256
from json import JSONDecodeError
from pathlib import Path
from typing import Iterable, NamedTuple, Optional, TypedDict
from cartridges import shared
from cartridges.game import Game
from cartridges.importer.location import Location, LocationSubPath
from cartridges.importer.source import (
SourceIterable,
SourceIterationResult,
URLExecutableSource,
)
def path_json_load(path: Path):
"""
Load JSON from the file at the given path
:raises OSError: if the file can't be opened
:raises JSONDecodeError: if the file isn't valid JSON
"""
with path.open("r", encoding="utf-8") as open_file:
return json.load(open_file)
class InvalidLibraryFileError(Exception):
pass
class InvalidInstalledFileError(Exception):
pass
class InvalidStoreFileError(Exception):
pass
class HeroicLibraryEntry(TypedDict):
app_name: str
installed: Optional[bool]
runner: str
title: str
developer: str
art_square: str
class SubSourceIterable(Iterable):
"""Class representing a Heroic sub-source"""
source: "HeroicSource"
source_iterable: "HeroicSourceIterable"
name: str
service: str
image_uri_params: str = ""
relative_library_path: Path
library_json_entries_key: str = "library"
def __init__(self, source, source_iterable) -> None:
self.source = source
self.source_iterable = source_iterable
@cached_property
def library_path(self) -> 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
def process_library_entry(self, entry: HeroicLibraryEntry) -> SourceIterationResult:
"""Build a Game from a Heroic library entry"""
app_name = entry["app_name"]
runner = entry["runner"]
# Build game
values = {
"source": f"{self.source.source_id}_{self.service}",
"added": shared.import_time,
"name": entry["title"],
"developer": entry.get("developer", None),
"game_id": self.source.game_id_format.format(
service=self.service, game_id=app_name
),
"executable": self.source.make_executable(runner=runner, app_name=app_name),
"hidden": self.source_iterable.is_hidden(app_name),
}
game = Game(values)
# Get the image path from the Heroic cache
# 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.locations.config.root / "images-cache" / digest
additional_data = {"local_image_path": image_path}
return (game, additional_data)
def __iter__(self):
"""
Iterate through the games with a generator
:raises InvalidLibraryFileError: on initial call if the library file is bad
"""
try:
iterator = iter(
path_json_load(self.library_path)[self.library_json_entries_key]
)
except (OSError, JSONDecodeError, TypeError, KeyError) as error:
raise InvalidLibraryFileError(
f"Invalid {self.library_path.name}"
) from error
for entry in iterator:
try:
yield self.process_library_entry(entry)
except KeyError as error:
logging.warning(
"Skipped invalid %s game %s",
self.name,
entry.get("app_name", "UNKNOWN"),
exc_info=error,
)
continue
class StoreSubSourceIterable(SubSourceIterable):
"""
Class representing a "store" sub source.
Games can be installed or not, this class does the check accordingly.
"""
relative_installed_path: Path
installed_app_names: set[str]
@cached_property
def installed_path(self) -> 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
@abstractmethod
def get_installed_app_names(self) -> set[str]:
"""
Get the sub source's installed app names as a set.
:raises InvalidInstalledFileError: if the installed file data cannot be read
Whenever possible, `__cause__` is set with the original exception
"""
def is_installed(self, app_name: str) -> bool:
return app_name in self.installed_app_names
def process_library_entry(self, entry):
# Skip games that are not installed
app_name = entry["app_name"]
if not self.is_installed(app_name):
logging.warning(
"Skipped %s game %s (%s): not installed",
self.service,
entry["title"],
app_name,
)
return None
# Process entry as normal
return super().process_library_entry(entry)
def __iter__(self):
"""
Iterate through the installed games with a generator
:raises InvalidLibraryFileError: on initial call if the library file is bad
:raises InvalidInstalledFileError: on initial call if the installed file is bad
"""
self.installed_app_names = self.get_installed_app_names()
yield from super().__iter__()
class SideloadIterable(SubSourceIterable):
name = "sideload"
service = "sideload"
relative_library_path = Path("sideload_apps") / "library.json"
library_json_entries_key = "games"
class LegendaryIterable(StoreSubSourceIterable):
name = "legendary"
service = "epic"
image_uri_params = "?h=400&resize=1&w=300"
relative_library_path = Path("store_cache") / "legendary_library.json"
# relative_installed_path = (
# Path("legendary") / "legendaryConfig" / "legendary" / "installed.json"
# )
@cached_property
def installed_path(self) -> Path:
"""Get the right path depending on the Heroic version"""
# TODO after Heroic 2.9 has been out for a while
# We should use the commented out relative_installed_path
# and remove this property override.
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")
# Heroic <= 2.8
elif heroic_config_path.is_relative_to(shared.flatpak_dir):
# Heroic flatpak
path = shared.flatpak_dir / "com.heroicgameslauncher.hgl" / "config"
logging.debug("Using Heroic flatpak <= 2.8 legendary file")
else:
# Heroic native
logging.debug("Using Heroic native <= 2.8 legendary file")
path = shared.home / ".config"
path = path / "legendary" / "installed.json"
logging.debug("Using Heroic %s installed.json path %s", self.name, path)
return path
def get_installed_app_names(self):
try:
return set(path_json_load(self.installed_path).keys())
except (OSError, JSONDecodeError, AttributeError) as error:
raise InvalidInstalledFileError(
f"Invalid {self.installed_path.name}"
) from error
class GogIterable(StoreSubSourceIterable):
name = "gog"
service = "gog"
library_json_entries_key = "games"
relative_library_path = Path("store_cache") / "gog_library.json"
relative_installed_path = Path("gog_store") / "installed.json"
def get_installed_app_names(self):
try:
return {
app_name
for entry in path_json_load(self.installed_path)["installed"]
if (app_name := entry.get("appName")) is not None
}
except (OSError, JSONDecodeError, KeyError, AttributeError) as error:
raise InvalidInstalledFileError(
f"Invalid {self.installed_path.name}"
) from error
class NileIterable(StoreSubSourceIterable):
name = "nile"
service = "amazon"
relative_library_path = Path("store_cache") / "nile_library.json"
relative_installed_path = Path("nile_config") / "nile" / "installed.json"
def get_installed_app_names(self):
try:
installed_json = path_json_load(self.installed_path)
return {
app_name
for entry in installed_json
if (app_name := entry.get("id")) is not None
}
except (OSError, JSONDecodeError, AttributeError) as error:
raise InvalidInstalledFileError(
f"Invalid {self.installed_path.name}"
) from error
class HeroicSourceIterable(SourceIterable):
source: "HeroicSource"
hidden_app_names: set[str] = set()
def is_hidden(self, app_name: str) -> bool:
return app_name in self.hidden_app_names
def get_hidden_app_names(self) -> set[str]:
"""Get the hidden app names from store/config.json
:raises InvalidStoreFileError: if the store is invalid for some reason
"""
try:
store = path_json_load(self.source.locations.config["store_config.json"])
self.hidden_app_names = {
app_name
for game in store["games"]["hidden"]
if (app_name := game.get("appName")) is not None
}
except KeyError:
logging.warning('No ["games"]["hidden"] key in Heroic store file')
except (OSError, JSONDecodeError, TypeError) as error:
logging.error("Invalid Heroic store file", exc_info=error)
raise InvalidStoreFileError() from error
def __iter__(self):
"""Generator method producing games from all the Heroic sub-sources"""
self.get_hidden_app_names()
# Get games from the sub sources
for sub_source_class in (
SideloadIterable,
LegendaryIterable,
GogIterable,
NileIterable,
):
sub_source = sub_source_class(self.source, self)
if not shared.schema.get_boolean("heroic-import-" + sub_source.service):
logging.debug("Skipping Heroic %s: disabled", sub_source.service)
continue
try:
sub_source_iterable = iter(sub_source)
yield from sub_source_iterable
except (InvalidLibraryFileError, InvalidInstalledFileError) as error:
logging.error(
"Skipping bad Heroic sub-source %s",
sub_source.service,
exc_info=error,
)
continue
class HeroicLocations(NamedTuple):
config: Location
class HeroicSource(URLExecutableSource):
"""Generic Heroic Games Launcher source"""
source_id = "heroic"
name = _("Heroic")
iterable_class = HeroicSourceIterable
url_format = "heroic://launch/{runner}/{app_name}"
available_on = {"linux", "win32"}
locations: HeroicLocations
@property
def game_id_format(self) -> str:
"""The string format used to construct game IDs"""
return self.source_id + "_{service}_{game_id}"
def __init__(self) -> None:
super().__init__()
self.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.appdata_dir / "heroic",
),
paths={
"config.json": LocationSubPath("config.json"),
"store_config.json": LocationSubPath("store/config.json"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)

View File

@@ -0,0 +1,424 @@
# importer.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from time import time
from typing import Any, Optional
from gi.repository import Adw, Gio, GLib, Gtk
from cartridges import shared
from cartridges.errors.error_producer import ErrorProducer
from cartridges.errors.friendly_error import FriendlyError
from cartridges.game import Game
from cartridges.importer.location import UnresolvableLocationError
from cartridges.importer.source import Source
from cartridges.store.managers.async_manager import AsyncManager
from cartridges.store.pipeline import Pipeline
# pylint: disable=too-many-instance-attributes
class Importer(ErrorProducer):
"""A class in charge of scanning sources for games"""
progressbar: Gtk.ProgressBar
import_statuspage: Adw.StatusPage
import_dialog: Adw.MessageDialog
summary_toast: Optional[Adw.Toast] = None
sources: set[Source]
n_source_tasks_created: int = 0
n_source_tasks_done: int = 0
n_pipelines_done: int = 0
game_pipelines: set[Pipeline]
removed_game_ids: set[str]
imported_game_ids: set[str]
close_req_id: int
def __init__(self) -> None:
super().__init__()
shared.import_time = int(time())
# TODO: make this stateful
shared.store.new_game_ids = set()
shared.store.duplicate_game_ids = set()
self.removed_game_ids = set()
self.imported_game_ids = set()
self.game_pipelines = set()
self.sources = set()
@property
def n_games_added(self) -> int:
return sum(
1 if not (pipeline.game.blacklisted or pipeline.game.removed) else 0
for pipeline in self.game_pipelines
)
@property
def pipelines_progress(self) -> float:
progress = sum(pipeline.progress for pipeline in self.game_pipelines)
try:
progress = progress / len(self.game_pipelines)
except ZeroDivisionError:
progress = 0
return progress # type: ignore
@property
def sources_progress(self) -> float:
try:
progress = self.n_source_tasks_done / self.n_source_tasks_created
except ZeroDivisionError:
progress = 0
return progress
@property
def finished(self) -> bool:
return (
self.n_source_tasks_created == self.n_source_tasks_done
and len(self.game_pipelines) == self.n_pipelines_done
)
def add_source(self, source: Source) -> None:
self.sources.add(source)
def run(self) -> None:
"""Use several Gio.Task to import games from added sources"""
shared.win.get_application().state = shared.AppState.IMPORT
if self.__class__.summary_toast:
self.__class__.summary_toast.dismiss()
shared.win.get_application().lookup_action("import").set_enabled(False)
shared.win.get_application().lookup_action("add_game").set_enabled(False)
self.create_dialog()
# Collect all errors and reset the cancellables for the managers
# - Only one importer exists at any given time
# - Every import starts fresh
self.collect_errors()
for manager in shared.store.managers.values():
manager.collect_errors()
if isinstance(manager, AsyncManager):
manager.reset_cancellable()
for source in self.sources:
logging.debug("Importing games from source %s", source.source_id)
task = Gio.Task.new(None, None, self.source_callback, (source,))
self.n_source_tasks_created += 1
task.run_in_thread(
lambda _task, _obj, _data, _cancellable, src=source: self.source_task_thread_func(
(src,)
)
)
self.progress_changed_callback()
def create_dialog(self) -> None:
"""Create the import dialog"""
self.progressbar = Gtk.ProgressBar(margin_start=12, margin_end=12)
self.import_statuspage = Adw.StatusPage(
title=_("Importing Games…"),
child=self.progressbar,
)
self.import_dialog = Adw.Window(
content=self.import_statuspage,
modal=True,
default_width=350,
default_height=-1,
transient_for=shared.win,
deletable=False,
)
self.close_req_id = self.import_dialog.connect(
"close-request", lambda *_: shared.win.close()
)
self.import_dialog.present()
def source_task_thread_func(self, data: tuple) -> None:
"""Source import task code"""
source: Source
source, *_rest = data
# Early exit if not available or not installed
if not source.is_available:
logging.info("Source %s skipped, not available", source.source_id)
return
try:
iterator = iter(source)
except UnresolvableLocationError:
logging.info("Source %s skipped, bad location", source.source_id)
return
# Get games from source
logging.info("Scanning source %s", source.source_id)
while True:
# Handle exceptions raised when iterating
try:
iteration_result = next(iterator)
except StopIteration:
break
except Exception as error: # pylint: disable=broad-exception-caught
logging.exception("%s in %s", type(error).__name__, source.source_id)
self.report_error(error)
continue
# Handle the result depending on its type
if isinstance(iteration_result, Game):
game = iteration_result
additional_data = {}
elif isinstance(iteration_result, tuple):
game, additional_data = iteration_result
elif iteration_result is None:
continue
else:
# Warn source implementers that an invalid type was produced
# Should not happen on production code
logging.warning(
"%s produced an invalid iteration return type %s",
source.source_id,
type(iteration_result),
)
continue
# Register game
pipeline: Pipeline = shared.store.add_game(game, additional_data)
if pipeline is not None:
logging.info("Imported %s (%s)", game.name, game.game_id)
pipeline.connect("advanced", self.pipeline_advanced_callback)
self.game_pipelines.add(pipeline)
def update_progressbar(self) -> None:
"""Update the progressbar to show the overall import progress"""
# Reserve 10% for the sources discovery, the rest is the pipelines
self.progressbar.set_fraction(
(0.1 * self.sources_progress) + (0.9 * self.pipelines_progress)
)
def source_callback(self, _obj: Any, _result: Any, data: tuple) -> None:
"""Callback executed when a source is fully scanned"""
source, *_rest = data
logging.debug("Import done for source %s", source.source_id)
self.n_source_tasks_done += 1
self.progress_changed_callback()
def pipeline_advanced_callback(self, pipeline: Pipeline) -> None:
"""Callback called when a pipeline for a game has advanced"""
if pipeline.is_done:
self.n_pipelines_done += 1
self.progress_changed_callback()
def progress_changed_callback(self) -> None:
"""
Callback called when the import process has progressed
Triggered when:
* All sources have been started
* A source finishes
* A pipeline finishes
"""
self.update_progressbar()
if self.finished:
self.import_callback()
def remove_games(self) -> None:
"""Set removed to True for missing games"""
if not shared.schema.get_boolean("remove-missing"):
return
for game in shared.store:
if game.removed:
continue
if game.source == "imported":
continue
if not shared.schema.get_boolean(game.base_source):
continue
if game.game_id in shared.store.duplicate_game_ids:
continue
if game.game_id in shared.store.new_game_ids:
continue
logging.debug("Removing missing game %s (%s)", game.name, game.game_id)
game.removed = True
game.save()
game.update()
self.removed_game_ids.add(game.game_id)
def import_callback(self) -> None:
"""Callback called when importing has finished"""
logging.info("Import done")
self.remove_games()
self.imported_game_ids = shared.store.new_game_ids
shared.store.new_game_ids = set()
shared.store.duplicate_game_ids = set()
# Disconnect the close-request signal that closes the main window
self.import_dialog.disconnect(self.close_req_id)
self.import_dialog.close()
self.__class__.summary_toast = self.create_summary_toast()
self.create_error_dialog()
shared.win.get_application().lookup_action("import").set_enabled(True)
shared.win.get_application().lookup_action("add_game").set_enabled(True)
shared.win.get_application().state = shared.AppState.DEFAULT
shared.win.create_source_rows()
def create_error_dialog(self) -> None:
"""Dialog containing all errors raised by importers"""
# Collect all errors that happened in the importer and the managers
errors = []
errors.extend(self.collect_errors())
for manager in shared.store.managers.values():
errors.extend(manager.collect_errors())
# Filter out non friendly errors
errors = set(
tuple(
(error.title, error.subtitle)
for error in (
filter(lambda error: isinstance(error, FriendlyError), errors)
)
)
)
# No error to display
if not errors:
self.timeout_toast()
return
# Create error dialog
dialog = Adw.MessageDialog()
dialog.set_heading(_("Warning"))
dialog.add_response("close", _("Dismiss"))
dialog.add_response("open_preferences_import", _("Preferences"))
dialog.set_default_response("open_preferences_import")
dialog.connect("response", self.dialog_response_callback)
dialog.set_transient_for(shared.win)
if len(errors) == 1:
dialog.set_heading((error := next(iter(errors)))[0])
dialog.set_body(error[1])
else:
# Display the errors in a list
list_box = Gtk.ListBox()
list_box.set_selection_mode(Gtk.SelectionMode.NONE)
list_box.set_css_classes(["boxed-list"])
list_box.set_margin_top(9)
for error in errors:
row = Adw.ActionRow.new()
row.set_title(error[0])
row.set_subtitle(error[1])
list_box.append(row)
dialog.set_body(_("The following errors occured during import:"))
dialog.set_extra_child(list_box)
dialog.present()
def undo_import(self, *_args: Any) -> None:
for game_id in self.imported_game_ids:
shared.store[game_id].removed = True
shared.store[game_id].update()
shared.store[game_id].save()
for game_id in self.removed_game_ids:
shared.store[game_id].removed = False
shared.store[game_id].update()
shared.store[game_id].save()
self.imported_game_ids = set()
self.removed_game_ids = set()
if self.__class__.summary_toast:
self.__class__.summary_toast.dismiss()
logging.info("Import undone")
def create_summary_toast(self) -> Adw.Toast:
"""N games imported, removed toast"""
toast = Adw.Toast(timeout=0, priority=Adw.ToastPriority.HIGH)
if not self.n_games_added:
toast_title = _("No new games found")
if not self.removed_game_ids:
toast.set_button_label(_("Preferences"))
toast.connect(
"button-clicked",
self.dialog_response_callback,
"open_preferences",
"import",
)
elif self.n_games_added == 1:
toast_title = _("1 game imported")
elif self.n_games_added > 1:
# The variable is the number of games
toast_title = _("{} games imported").format(self.n_games_added)
if (removed_length := len(self.removed_game_ids)) == 1:
# A single game removed
toast_title += ", " + _("1 removed")
elif removed_length > 1:
# The variable is the number of games removed
toast_title += ", " + _("{} removed").format(removed_length)
if self.n_games_added or self.removed_game_ids:
toast.set_button_label(_("Undo"))
toast.connect("button-clicked", self.undo_import)
toast.set_title(toast_title)
shared.win.toast_overlay.add_toast(toast)
return toast
def open_preferences(
self,
page_name: Optional[str] = None,
expander_row: Optional[Adw.ExpanderRow] = None,
) -> Adw.PreferencesWindow:
return shared.win.get_application().on_preferences_action(
page_name=page_name, expander_row=expander_row
)
def timeout_toast(self, *_args: Any) -> None:
"""Manually timeout the toast after the user has dismissed all warnings"""
if self.__class__.summary_toast:
GLib.timeout_add_seconds(5, self.__class__.summary_toast.dismiss)
def dialog_response_callback(self, _widget: Any, response: str, *args: Any) -> None:
"""Handle after-import dialogs callback"""
logging.debug("After-import dialog response: %s (%s)", response, str(args))
if response == "open_preferences":
self.open_preferences(*args)
elif response == "open_preferences_import":
self.open_preferences(*args).connect("close-request", self.timeout_toast)
else:
self.timeout_toast()

View File

@@ -0,0 +1,104 @@
# itch_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from shutil import rmtree
from sqlite3 import connect
from typing import NamedTuple
from cartridges import shared
from cartridges.game import Game
from cartridges.importer.location import Location, LocationSubPath
from cartridges.importer.source import SourceIterable, URLExecutableSource
from cartridges.utils.sqlite import copy_db
class ItchSourceIterable(SourceIterable):
source: "ItchSource"
def __iter__(self):
"""Generator method producing games"""
# Query the database
db_request = """
SELECT
games.id,
games.title,
games.cover_url,
games.still_cover_url,
caves.id
FROM
'caves'
INNER JOIN
'games'
ON
caves.game_id = games.id
;
"""
db_path = copy_db(self.source.locations.config["butler.db"])
connection = connect(db_path)
cursor = connection.execute(db_request)
# Create games from the db results
for row in cursor:
values = {
"added": shared.import_time,
"source": self.source.source_id,
"name": row[1],
"game_id": self.source.game_id_format.format(game_id=row[0]),
"executable": self.source.make_executable(cave_id=row[4]),
}
additional_data = {"online_cover_url": row[3] or row[2]}
game = Game(values)
yield (game, additional_data)
# Cleanup
rmtree(str(db_path.parent))
class ItchLocations(NamedTuple):
config: Location
class ItchSource(URLExecutableSource):
source_id = "itch"
name = _("itch")
iterable_class = ItchSourceIterable
url_format = "itch://caves/{cave_id}/launch"
available_on = {"linux", "win32"}
locations: ItchLocations
def __init__(self) -> None:
super().__init__()
self.locations = ItchLocations(
Location(
schema_key="itch-location",
candidates=(
shared.flatpak_dir / "io.itch.itch" / "config" / "itch",
shared.config_dir / "itch",
shared.home / ".config" / "itch",
shared.appdata_dir / "itch",
),
paths={
"butler.db": LocationSubPath("db/butler.db"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)

View File

@@ -0,0 +1,119 @@
# legendary_source.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
from json import JSONDecodeError
from typing import NamedTuple
from cartridges import shared
from cartridges.game import Game
from cartridges.importer.location import Location, LocationSubPath
from cartridges.importer.source import (
ExecutableFormatSource,
SourceIterable,
SourceIterationResult,
)
class LegendarySourceIterable(SourceIterable):
source: "LegendarySource"
def game_from_library_entry(self, entry: dict) -> SourceIterationResult:
# Skip non-games
if entry["is_dlc"]:
return None
# Build game
app_name = entry["app_name"]
values = {
"added": shared.import_time,
"source": self.source.source_id,
"name": entry["title"],
"game_id": self.source.game_id_format.format(game_id=app_name),
"executable": self.source.make_executable(app_name=app_name),
}
data = {}
# Get additional metadata from file (optional)
metadata_file = self.source.locations.config["metadata"] / f"{app_name}.json"
try:
metadata = json.load(metadata_file.open())
values["developer"] = metadata["metadata"]["developer"]
for image_entry in metadata["metadata"]["keyImages"]:
if image_entry["type"] == "DieselGameBoxTall":
data["online_cover_url"] = image_entry["url"]
break
except (JSONDecodeError, OSError, KeyError):
pass
game = Game(values)
return (game, data)
def __iter__(self):
# Open library
file = self.source.locations.config["installed.json"]
try:
library: dict = json.load(file.open())
except (JSONDecodeError, OSError):
logging.warning("Couldn't open Legendary file: %s", str(file))
return
# Generate games from library
for entry in library.values():
try:
result = self.game_from_library_entry(entry)
except KeyError as error:
# Skip invalid games
logging.warning(
"Invalid Legendary game skipped in %s", str(file), exc_info=error
)
continue
yield result
class LegendaryLocations(NamedTuple):
config: Location
class LegendarySource(ExecutableFormatSource):
source_id = "legendary"
name = _("Legendary")
executable_format = "legendary launch {app_name}"
available_on = {"linux"}
iterable_class = LegendarySourceIterable
locations: LegendaryLocations
def __init__(self) -> None:
super().__init__()
self.locations = LegendaryLocations(
Location(
schema_key="legendary-location",
candidates=(
shared.config_dir / "legendary",
shared.home / ".config" / "legendary",
),
paths={
"installed.json": LocationSubPath("installed.json"),
"metadata": LocationSubPath("metadata", True),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)

View File

@@ -0,0 +1,102 @@
import logging
from os import PathLike
from pathlib import Path
from typing import Iterable, Mapping, NamedTuple, Optional
from cartridges import shared
PathSegment = str | PathLike | Path
PathSegments = Iterable[PathSegment]
Candidate = PathSegments
class LocationSubPath(NamedTuple):
segment: PathSegment
is_directory: bool = False
class UnresolvableLocationError(Exception):
pass
class Location:
"""
Class representing a filesystem location
* A location may have multiple candidate roots
* The path in the schema is always favored
* From the candidate root, multiple subpaths should exist for it to be valid
* 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, LocationSubPath]
invalid_subtitle: str
root: Optional[Path] = None
def __init__(
self,
schema_key: str,
candidates: Iterable[Candidate],
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 segment, is_directory in self.paths.values():
path = Path(candidate) / segment
if is_directory:
if not path.is_dir():
return False
else:
if not path.is_file():
return False
return True
def resolve(self) -> None:
"""Choose a root path from the candidates for the location.
If none fits, raise an UnresolvableLocationError"""
if self.root is not None:
return
# Get the schema candidate
schema_candidate = shared.schema.get_string(self.schema_key)
# Find the first matching candidate
for candidate in (schema_candidate, *self.candidates):
candidate = Path(candidate).expanduser()
if not self.check_candidate(candidate):
continue
self.root = candidate
break
else:
# No good candidate found
raise UnresolvableLocationError()
# Update the schema with the found candidate
value = str(candidate)
shared.schema.set_string(self.schema_key, value)
logging.debug("Resolved value for schema key %s: %s", self.schema_key, value)
def __getitem__(self, key: str) -> Optional[Path]:
"""Get the computed path from its key for the location"""
self.resolve()
if self.root:
return self.root / self.paths[key].segment
return None

View File

@@ -0,0 +1,132 @@
# lutris_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from shutil import rmtree
from sqlite3 import connect
from typing import NamedTuple
from cartridges import shared
from cartridges.game import Game
from cartridges.importer.location import Location, LocationSubPath
from cartridges.importer.source import SourceIterable, URLExecutableSource
from cartridges.utils.sqlite import copy_db
class LutrisSourceIterable(SourceIterable):
source: "LutrisSource"
def __iter__(self):
"""Generator method producing games"""
# Query the database
request = """
SELECT id, name, slug, runner, hidden
FROM 'games'
WHERE
name IS NOT NULL
AND slug IS NOT NULL
AND configPath IS NOT NULL
AND installed
AND (runner IS NOT "steam" OR :import_steam)
AND (runner IS NOT "flatpak" OR :import_flatpak)
;
"""
params = {
"import_steam": shared.schema.get_boolean("lutris-import-steam"),
"import_flatpak": shared.schema.get_boolean("lutris-import-flatpak"),
}
db_path = copy_db(self.source.locations.config["pga.db"])
connection = connect(db_path)
cursor = connection.execute(request, params)
# Create games from the DB results
for row in cursor:
# Create game
values = {
"added": shared.import_time,
"hidden": row[4],
"name": row[1],
"source": f"{self.source.source_id}_{row[3]}",
"game_id": self.source.game_id_format.format(
runner=row[3], game_id=row[0]
),
"executable": self.source.make_executable(game_id=row[0]),
}
game = Game(values)
# Get official image path
image_path = self.source.locations.cache["coverart"] / f"{row[2]}.jpg"
additional_data = {"local_image_path": image_path}
yield (game, additional_data)
# Cleanup
rmtree(str(db_path.parent))
class LutrisLocations(NamedTuple):
config: Location
cache: Location
class LutrisSource(URLExecutableSource):
"""Generic Lutris source"""
source_id = "lutris"
name = _("Lutris")
iterable_class = LutrisSourceIterable
url_format = "lutris:rungameid/{game_id}"
available_on = {"linux"}
# FIXME possible bug: config picks ~/.var... and cache picks ~/.local...
locations: LutrisLocations
@property
def game_id_format(self):
return self.source_id + "_{runner}_{game_id}"
def __init__(self) -> None:
super().__init__()
self.locations = LutrisLocations(
Location(
schema_key="lutris-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris",
shared.data_dir / "lutris",
shared.home / ".local" / "share" / "lutris",
),
paths={
"pga.db": LocationSubPath("pga.db"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
),
Location(
schema_key="lutris-cache-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "cache" / "lutris",
shared.cache_dir / "lutris",
shared.home / ".cache" / "lutris",
),
paths={
"coverart": LocationSubPath("coverart", True),
},
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
),
)

View File

@@ -0,0 +1,255 @@
# 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 <http://www.gnu.org/licenses/>.
#
# 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 shlex import quote as shell_quote
from typing import NamedTuple
from cartridges import shared
from cartridges.errors.friendly_error import FriendlyError
from cartridges.game import Game
from cartridges.importer.location import (
Location,
LocationSubPath,
UnresolvableLocationError,
)
from cartridges.importer.source import Source, SourceIterable
from cartridges.importer.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):
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": shared.import_time,
"name": item["label"],
"game_id": self.source.game_id_format.format(game_id=game_id),
"executable": self.source.make_executable(
core_path=core_path,
rom_path=item["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
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 working
# 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:
"""
Get the RetroArch installed via Steam location
:raise UnresolvableLocationError: if Steam isn't installed
:raise KeyError: if there is no libraryfolders.vdf subpath
:raise OSError: if libraryfolders.vdf can't be opened
:raise ValueError: if RetroArch isn't installed through Steam
"""
# 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")
# Not found
raise ValueError("RetroArch not found in Steam library")
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)

View File

@@ -0,0 +1,126 @@
# source.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import sys
from abc import abstractmethod
from collections.abc import Iterable
from typing import Any, Collection, Generator, Optional
from cartridges.game import Game
from cartridges.importer.location import Location
# Type of the data returned by iterating on a Source
SourceIterationResult = Optional[Game | tuple[Game, tuple[Any]]]
class SourceIterable(Iterable):
"""Data producer for a source of games"""
source: "Source"
def __init__(self, source: "Source") -> None:
self.source = source
@abstractmethod
def __iter__(self) -> Generator[SourceIterationResult, None, None]:
"""
Method that returns a generator that produces games
* Should be implemented as a generator method
* May yield `None` when an iteration hasn't produced a game
* In charge of handling per-game errors
* Returns when exhausted
"""
class Source(Iterable):
"""Source of games. E.g an installed app with a config file that lists game directories"""
source_id: str
name: str
variant: Optional[str] = None
available_on: set[str] = set()
iterable_class: type[SourceIterable]
# NOTE: Locations must be set at __init__ time, not in the class definition.
# They must not be shared between source instances.
locations: Collection[Location]
@property
def full_name(self) -> str:
"""The source's full name"""
full_name_ = self.name
if self.variant:
full_name_ += f" ({self.variant})"
return full_name_
@property
def game_id_format(self) -> str:
"""The string format used to construct game IDs"""
return self.source_id + "_{game_id}"
@property
def is_available(self) -> bool:
return sys.platform in self.available_on
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]:
"""
Get an iterator for the source
:raises UnresolvableLocationError: Not iterable if any of the locations are unresolvable
"""
for location in self.locations:
location.resolve()
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(ExecutableFormatSource):
"""Source class that use custom URLs to start games"""
url_format: str
@property
def executable_format(self) -> str:
match sys.platform:
case "win32":
return "start " + self.url_format
case "linux":
return "xdg-open " + self.url_format
case other:
raise NotImplementedError(
f"No URL handler command available for {other}"
)

View File

@@ -0,0 +1,140 @@
# steam_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
import re
from pathlib import Path
from typing import Iterable, NamedTuple
from cartridges import shared
from cartridges.game import Game
from cartridges.importer.location import Location, LocationSubPath
from cartridges.importer.source import SourceIterable, URLExecutableSource
from cartridges.utils.steam import SteamFileHelper, SteamInvalidManifestError
class SteamSourceIterable(SourceIterable):
source: "SteamSource"
def get_manifest_dirs(self) -> Iterable[Path]:
"""Get dirs that contain Steam app manifests"""
libraryfolders_path = self.source.locations.data["libraryfolders.vdf"]
with open(libraryfolders_path, "r", encoding="utf-8") as file:
contents = file.read()
return [
Path(path) / "steamapps"
for path in re.findall('"path"\\s+"(.*)"\n', contents, re.IGNORECASE)
]
def get_manifests(self) -> Iterable[Path]:
"""Get app manifests"""
manifests = set()
for steamapps_dir in self.get_manifest_dirs():
if not steamapps_dir.is_dir():
continue
manifests.update(
[
manifest
for manifest in steamapps_dir.glob("appmanifest_*.acf")
if manifest.is_file()
]
)
return manifests
def __iter__(self):
"""Generator method producing games"""
appid_cache = set()
manifests = self.get_manifests()
for manifest in manifests:
# Get metadata from manifest
steam = SteamFileHelper()
try:
local_data = steam.get_manifest_data(manifest)
except (OSError, SteamInvalidManifestError) as error:
logging.debug("Couldn't load appmanifest %s", manifest, exc_info=error)
continue
# Skip non installed games
installed_mask = 4
if not int(local_data["stateflags"]) & installed_mask:
logging.debug("Skipped %s: not installed", manifest)
continue
# Skip duplicate appids
appid = local_data["appid"]
if appid in appid_cache:
logging.debug("Skipped %s: appid already seen during import", manifest)
continue
appid_cache.add(appid)
# Build game from local data
values = {
"added": shared.import_time,
"name": local_data["name"],
"source": self.source.source_id,
"game_id": self.source.game_id_format.format(game_id=appid),
"executable": self.source.make_executable(game_id=appid),
}
game = Game(values)
# Add official cover image
image_path = (
self.source.locations.data["librarycache"]
/ f"{appid}_library_600x900.jpg"
)
additional_data = {"local_image_path": image_path, "steam_appid": appid}
yield (game, additional_data)
class SteamLocations(NamedTuple):
data: Location
class SteamSource(URLExecutableSource):
source_id = "steam"
name = _("Steam")
available_on = {"linux", "win32"}
iterable_class = SteamSourceIterable
url_format = "steam://rungameid/{game_id}"
locations: SteamLocations
def __init__(self) -> None:
super().__init__()
self.locations = SteamLocations(
Location(
schema_key="steam-location",
candidates=(
shared.home / ".steam" / "steam",
shared.data_dir / "Steam",
shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam",
shared.programfiles32_dir / "Steam",
),
paths={
"libraryfolders.vdf": LocationSubPath(
"steamapps/libraryfolders.vdf"
),
"librarycache": LocationSubPath("appcache/librarycache", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
)

View File

@@ -0,0 +1,44 @@
# color_log_formatter.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from logging import Formatter, LogRecord
class ColorLogFormatter(Formatter):
"""Formatter that outputs logs in a colored format"""
RESET = "\033[0m"
DIM = "\033[2m"
BOLD = "\033[1m"
RED = "\033[31m"
YELLOW = "\033[33m"
def format(self, record: LogRecord) -> str:
super_format = super().format(record)
match record.levelname:
case "CRITICAL":
return self.BOLD + self.RED + super_format + self.RESET
case "ERROR":
return self.RED + super_format + self.RESET
case "WARNING":
return self.YELLOW + super_format + self.RESET
case "DEBUG":
return self.DIM + super_format + self.RESET
case _other:
return super_format

View File

@@ -0,0 +1,140 @@
# session_file_handler.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import lzma
from io import TextIOWrapper
from logging import StreamHandler
from lzma import FORMAT_XZ, PRESET_DEFAULT
from os import PathLike
from pathlib import Path
from typing import Optional
from cartridges import shared
class SessionFileHandler(StreamHandler):
"""
A logging handler that writes to a new file on every app restart.
The files are compressed and older sessions logs are kept up to a small limit.
"""
NUMBER_SUFFIX_POSITION = 1
backup_count: int
filename: Path
log_file: Optional[TextIOWrapper] = None
def create_dir(self) -> None:
"""Create the log dir if needed"""
self.filename.parent.mkdir(exist_ok=True, parents=True)
def path_is_logfile(self, path: Path) -> bool:
return path.is_file() and path.name.startswith(self.filename.stem)
def path_has_number(self, path: Path) -> bool:
try:
int(path.suffixes[self.NUMBER_SUFFIX_POSITION][1:])
except (ValueError, IndexError):
return False
return True
def get_path_number(self, path: Path) -> int:
"""Get the number extension in the filename as an int"""
suffixes = path.suffixes
number = (
0
if not self.path_has_number(path)
else int(suffixes[self.NUMBER_SUFFIX_POSITION][1:])
)
return number
def set_path_number(self, path: Path, number: int) -> str:
"""Set or add the number extension in the filename"""
suffixes = path.suffixes
if self.path_has_number(path):
suffixes.pop(self.NUMBER_SUFFIX_POSITION)
suffixes.insert(self.NUMBER_SUFFIX_POSITION, f".{number}")
stem = path.name.split(".", maxsplit=1)[0]
new_name = stem + "".join(suffixes)
return new_name
def file_sort_key(self, path: Path) -> int:
"""Key function used to sort files"""
return self.get_path_number(path) if self.path_has_number(path) else 0
def get_logfiles(self) -> list[Path]:
"""Get the log files"""
logfiles = list(filter(self.path_is_logfile, self.filename.parent.iterdir()))
logfiles.sort(key=self.file_sort_key, reverse=True)
return logfiles
def rotate_file(self, path: Path) -> None:
"""Rotate a file's number suffix and remove it if it's too old"""
# If uncompressed, compress
if not path.name.endswith(".xz"):
try:
with open(path, "r", encoding="utf-8") as original_file:
original_data = original_file.read()
except UnicodeDecodeError:
# If the file is corrupted, throw it away
path.unlink()
return
# Compress the file
compressed_path = path.with_suffix(path.suffix + ".xz")
with lzma.open(
compressed_path,
"wt",
format=FORMAT_XZ,
preset=PRESET_DEFAULT,
encoding="utf-8",
) as lzma_file:
lzma_file.write(original_data)
path.unlink()
path = compressed_path
# Rename with new number suffix
new_number = self.get_path_number(path) + 1
new_path_name = self.set_path_number(path, new_number)
path = path.rename(path.with_name(new_path_name))
# Remove older files
if new_number > self.backup_count:
path.unlink()
return
def rotate(self) -> None:
"""Rotate the numbered suffix on the log files and remove old ones"""
for path in self.get_logfiles():
self.rotate_file(path)
def __init__(self, filename: PathLike, backup_count: int = 2) -> None:
self.filename = Path(filename)
self.backup_count = backup_count
self.create_dir()
self.rotate()
self.log_file = open(self.filename, "w", encoding="utf-8")
shared.log_files = self.get_logfiles()
super().__init__(self.log_file)
def close(self) -> None:
if self.log_file:
self.log_file.close()
super().close()

109
cartridges/logging/setup.py Normal file
View File

@@ -0,0 +1,109 @@
# setup.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
import logging.config as logging_dot_config
import os
import platform
import subprocess
import sys
from cartridges import shared
def setup_logging() -> None:
"""Intitate the app's logging"""
is_dev = shared.PROFILE == "development"
profile_app_log_level = "DEBUG" if is_dev else "INFO"
profile_lib_log_level = "INFO" if is_dev else "WARNING"
app_log_level = os.environ.get("LOGLEVEL", profile_app_log_level).upper()
lib_log_level = os.environ.get("LIBLOGLEVEL", profile_lib_log_level).upper()
log_filename = shared.cache_dir / "cartridges" / "logs" / "cartridges.log"
config = {
"version": 1,
"formatters": {
"file_formatter": {
"format": "%(asctime)s - %(levelname)s: %(message)s",
"datefmt": "%M:%S",
},
"console_formatter": {
"format": "%(name)s %(levelname)s - %(message)s",
"class": "cartridges.logging.color_log_formatter.ColorLogFormatter",
},
},
"handlers": {
"file_handler": {
"class": "cartridges.logging.session_file_handler.SessionFileHandler",
"formatter": "file_formatter",
"level": "DEBUG",
"filename": log_filename,
"backup_count": 2,
},
"app_console_handler": {
"class": "logging.StreamHandler",
"formatter": "console_formatter",
"level": app_log_level,
},
"lib_console_handler": {
"class": "logging.StreamHandler",
"formatter": "console_formatter",
"level": lib_log_level,
},
},
"loggers": {
"PIL": {
"handlers": ["lib_console_handler", "file_handler"],
"propagate": False,
"level": "WARNING",
},
"urllib3": {
"handlers": ["lib_console_handler", "file_handler"],
"propagate": False,
"level": "NOTSET",
},
},
"root": {
"level": "NOTSET",
"handlers": ["app_console_handler", "file_handler"],
},
}
logging_dot_config.dictConfig(config)
def log_system_info() -> None:
"""Log system debug information"""
logging.debug("Starting %s v%s (%s)", shared.APP_ID, shared.VERSION, shared.PROFILE)
logging.debug("Python version: %s", sys.version)
if os.getenv("FLATPAK_ID") == shared.APP_ID:
process = subprocess.run(
("flatpak-spawn", "--host", "flatpak", "--version"),
capture_output=True,
encoding="utf-8",
check=False,
)
logging.debug("Flatpak version: %s", process.stdout.rstrip())
logging.debug("Platform: %s", platform.platform())
if os.name == "posix":
for key, value in platform.uname()._asdict().items():
logging.debug("\t%s: %s", key.title(), value)
logging.debug("" * 37)

367
cartridges/main.py Normal file
View File

@@ -0,0 +1,367 @@
# main.py
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import lzma
import os
import shlex
import sys
from typing import Any, Optional
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
# pylint: disable=wrong-import-position
from gi.repository import Adw, Gio, GLib, Gtk
from cartridges import shared
from cartridges.details_window import DetailsWindow
from cartridges.game import Game
from cartridges.importer.bottles_source import BottlesSource
from cartridges.importer.desktop_source import DesktopSource
from cartridges.importer.flatpak_source import FlatpakSource
from cartridges.importer.heroic_source import HeroicSource
from cartridges.importer.importer import Importer
from cartridges.importer.itch_source import ItchSource
from cartridges.importer.legendary_source import LegendarySource
from cartridges.importer.lutris_source import LutrisSource
from cartridges.importer.retroarch_source import RetroarchSource
from cartridges.importer.steam_source import SteamSource
from cartridges.logging.setup import log_system_info, setup_logging
from cartridges.preferences import PreferencesWindow
from cartridges.store.managers.cover_manager import CoverManager
from cartridges.store.managers.display_manager import DisplayManager
from cartridges.store.managers.file_manager import FileManager
from cartridges.store.managers.sgdb_manager import SgdbManager
from cartridges.store.managers.steam_api_manager import SteamAPIManager
from cartridges.store.store import Store
from cartridges.utils.migrate_files_v1_to_v2 import migrate_files_v1_to_v2
from cartridges.utils.run_executable import run_executable
from cartridges.window import CartridgesWindow
class CartridgesApplication(Adw.Application):
state = shared.AppState.DEFAULT
win: CartridgesWindow
init_search_term: Optional[str] = None
def __init__(self) -> None:
shared.store = Store()
super().__init__(
application_id=shared.APP_ID,
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
)
def do_activate(self) -> None: # pylint: disable=arguments-differ
"""Called on app creation"""
setup_logging()
log_system_info()
if os.name == "nt":
migrate_files_v1_to_v2()
# Set fallback icon-name
Gtk.Window.set_default_icon_name(shared.APP_ID)
# Create the main window
win = self.props.active_window # pylint: disable=no-member
if not win:
shared.win = win = CartridgesWindow(application=self)
# Save window geometry
shared.state_schema.bind(
"width", shared.win, "default-width", Gio.SettingsBindFlags.DEFAULT
)
shared.state_schema.bind(
"height", shared.win, "default-height", Gio.SettingsBindFlags.DEFAULT
)
shared.state_schema.bind(
"is-maximized", shared.win, "maximized", Gio.SettingsBindFlags.DEFAULT
)
# Load games from disk
shared.store.add_manager(FileManager(), False)
shared.store.add_manager(DisplayManager())
self.state = shared.AppState.LOAD_FROM_DISK
self.load_games_from_disk()
self.state = shared.AppState.DEFAULT
shared.win.create_source_rows()
# Add rest of the managers for game imports
shared.store.add_manager(CoverManager())
shared.store.add_manager(SteamAPIManager())
shared.store.add_manager(SgdbManager())
shared.store.toggle_manager_in_pipelines(FileManager, True)
# Create actions
self.create_actions(
{
("quit", ("<primary>q",)),
("about",),
("preferences", ("<primary>comma",)),
("launch_game",),
("hide_game",),
("edit_game",),
("add_game", ("<primary>n",)),
("import", ("<primary>i",)),
("remove_game_details_view", ("Delete",)),
("remove_game",),
("igdb_search",),
("sgdb_search",),
("protondb_search",),
("lutris_search",),
("hltb_search",),
("show_sidebar", ("F9",), shared.win),
("show_hidden", ("<primary>h",), shared.win),
("go_to_parent", ("<alt>Up",), shared.win),
("go_home", ("<alt>Home",), shared.win),
("toggle_search", ("<primary>f",), shared.win),
("escape", ("Escape",), shared.win),
("undo", ("<primary>z",), shared.win),
("open_menu", ("F10",), shared.win),
("close", ("<primary>w",), shared.win),
}
)
sort_action = Gio.SimpleAction.new_stateful(
"sort_by", GLib.VariantType.new("s"), GLib.Variant("s", "a-z")
)
sort_action.connect("activate", shared.win.on_sort_action)
shared.win.add_action(sort_action)
shared.win.on_sort_action(
sort_action, shared.state_schema.get_value("sort-mode")
)
if self.init_search_term: # For command line activation
shared.win.search_bar.set_search_mode(True)
shared.win.search_entry.set_text(self.init_search_term)
shared.win.search_entry.set_position(-1)
shared.win.present()
def do_command_line(self, command_line) -> int:
for index, arg in enumerate(args := command_line.get_arguments()):
if arg == "--search":
try:
self.init_search_term = args[index + 1]
except IndexError:
pass
break
if arg == "--launch":
try:
game_id = args[index + 1]
data = json.load((shared.games_dir / (game_id + ".json")).open("r"))
executable = (
shlex.join(data["executable"])
if isinstance(data["executable"], list)
else data["executable"]
)
name = data["name"]
run_executable(executable)
except (IndexError, KeyError, OSError, json.decoder.JSONDecodeError):
return 1
notification = Gio.Notification.new(_("Cartridges"))
notification.set_body(_("{} launched").format(name))
self.send_notification(
"launch",
notification,
)
return 0
self.activate()
return 0
def load_games_from_disk(self) -> None:
if shared.games_dir.is_dir():
for game_file in shared.games_dir.iterdir():
try:
data = json.load(game_file.open())
except (OSError, json.decoder.JSONDecodeError):
continue
game = Game(data)
shared.store.add_game(game, {"skip_save": True})
def get_source_name(self, source_id: str) -> Any:
if source_id == "all":
name = _("All Games")
elif source_id == "imported":
name = _("Added")
else:
name = globals()[f'{source_id.split("_")[0].title()}Source'].name
return name
def on_about_action(self, *_args: Any) -> None:
# Get the debug info from the log files
debug_str = ""
for index, path in enumerate(shared.log_files):
# Add a horizontal line between runs
if index > 0:
debug_str += "" * 37 + "\n"
# Add the run's logs
log_file = (
lzma.open(path, "rt", encoding="utf-8")
if path.name.endswith(".xz")
else open(path, "r", encoding="utf-8")
)
debug_str += log_file.read()
log_file.close()
about = Adw.AboutWindow.new_from_appdata(
shared.PREFIX + "/" + shared.APP_ID + ".metainfo.xml", shared.VERSION
)
about.set_transient_for(shared.win)
about.set_developers(
(
"kramo https://kramo.hu",
"Geoffrey Coulaud https://geoffrey-coulaud.fr",
"Rilic https://rilic.red",
"Arcitec https://github.com/Arcitec",
"Paweł Lidwin https://github.com/imLinguin",
"Domenico https://github.com/Domefemia",
"Rafael Mardojai CM https://mardojai.com",
"Clara Hobbs https://github.com/Ratfink",
)
)
about.set_designers(("kramo https://kramo.hu",))
about.set_copyright("© 2022-2023 kramo")
# Translators: Replace this with your name for it to show up in the about window
about.set_translator_credits = (_("translator_credits"),)
about.set_debug_info(debug_str)
about.set_debug_info_filename("cartridges.log")
about.add_legal_section(
"Steam Branding",
"© 2023 Valve Corporation",
Gtk.License.CUSTOM,
"Steam and the Steam logo are trademarks and/or registered trademarks of Valve Corporation in the U.S. and/or other countries.", # pylint: disable=line-too-long
)
about.present()
def on_preferences_action(
self,
_action: Any = None,
_parameter: Any = None,
page_name: Optional[str] = None,
expander_row: Optional[str] = None,
) -> CartridgesWindow:
win = PreferencesWindow()
if page_name:
win.set_visible_page_name(page_name)
if expander_row:
getattr(win, expander_row).set_expanded(True)
win.present()
return win
def on_launch_game_action(self, *_args: Any) -> None:
shared.win.active_game.launch()
def on_hide_game_action(self, *_args: Any) -> None:
shared.win.active_game.toggle_hidden()
def on_edit_game_action(self, *_args: Any) -> None:
DetailsWindow(shared.win.active_game)
def on_add_game_action(self, *_args: Any) -> None:
DetailsWindow()
def on_import_action(self, *_args: Any) -> None:
shared.importer = Importer()
if shared.schema.get_boolean("lutris"):
shared.importer.add_source(LutrisSource())
if shared.schema.get_boolean("steam"):
shared.importer.add_source(SteamSource())
if shared.schema.get_boolean("heroic"):
shared.importer.add_source(HeroicSource())
if shared.schema.get_boolean("bottles"):
shared.importer.add_source(BottlesSource())
if shared.schema.get_boolean("flatpak"):
shared.importer.add_source(FlatpakSource())
if shared.schema.get_boolean("desktop"):
shared.importer.add_source(DesktopSource())
if shared.schema.get_boolean("itch"):
shared.importer.add_source(ItchSource())
if shared.schema.get_boolean("legendary"):
shared.importer.add_source(LegendarySource())
if shared.schema.get_boolean("retroarch"):
shared.importer.add_source(RetroarchSource())
shared.importer.run()
def on_remove_game_action(self, *_args: Any) -> None:
shared.win.active_game.remove_game()
def on_remove_game_details_view_action(self, *_args: Any) -> None:
if shared.win.navigation_view.get_visible_page() == shared.win.details_page:
self.on_remove_game_action()
def search(self, uri: str) -> None:
Gio.AppInfo.launch_default_for_uri(f"{uri}{shared.win.active_game.name}")
def on_igdb_search_action(self, *_args: Any) -> None:
self.search("https://www.igdb.com/search?type=1&q=")
def on_sgdb_search_action(self, *_args: Any) -> None:
self.search("https://www.steamgriddb.com/search/grids?term=")
def on_protondb_search_action(self, *_args: Any) -> None:
self.search("https://www.protondb.com/search?q=")
def on_lutris_search_action(self, *_args: Any) -> None:
self.search("https://lutris.net/games?q=")
def on_hltb_search_action(self, *_args: Any) -> None:
self.search("https://howlongtobeat.com/?q=")
def on_quit_action(self, *_args: Any) -> None:
self.quit()
def create_actions(self, actions: set) -> None:
for action in actions:
simple_action = Gio.SimpleAction.new(action[0], None)
scope = action[2] if action[2:3] else self
simple_action.connect("activate", getattr(scope, f"on_{action[0]}_action"))
if action[1:2]:
self.set_accels_for_action(
f"app.{action[0]}" if scope == self else f"win.{action[0]}",
action[1],
)
scope.add_action(simple_action)
def main(_version: int) -> Any:
"""App entry point"""
app = CartridgesApplication()
return app.run(sys.argv)

31
cartridges/meson.build Normal file
View File

@@ -0,0 +1,31 @@
moduledir = join_paths(python_dir, 'cartridges')
configure_file(
input: 'cartridges.in',
output: 'cartridges',
configuration: conf,
install: true,
install_dir: get_option('bindir')
)
install_subdir('importer', install_dir: moduledir)
install_subdir('utils', install_dir: moduledir)
install_subdir('store', install_dir: moduledir)
install_subdir('logging', install_dir: moduledir)
install_subdir('errors', install_dir: moduledir)
install_data(
[
'main.py',
'window.py',
'preferences.py',
'details_window.py',
'game.py',
'game_cover.py',
configure_file(
input: 'shared.py.in',
output: 'shared.py',
configuration: conf
)
],
install_dir: moduledir
)

440
cartridges/preferences.py Normal file
View File

@@ -0,0 +1,440 @@
# preferences.py
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
import re
from pathlib import Path
from shutil import rmtree
from typing import Any, Callable, Optional
from gi.repository import Adw, Gio, GLib, Gtk
from cartridges import shared
from cartridges.errors.friendly_error import FriendlyError
from cartridges.game import Game
from cartridges.importer.bottles_source import BottlesSource
from cartridges.importer.flatpak_source import FlatpakSource
from cartridges.importer.heroic_source import HeroicSource
from cartridges.importer.itch_source import ItchSource
from cartridges.importer.legendary_source import LegendarySource
from cartridges.importer.location import UnresolvableLocationError
from cartridges.importer.lutris_source import LutrisSource
from cartridges.importer.retroarch_source import RetroarchSource
from cartridges.importer.source import Source
from cartridges.importer.steam_source import SteamSource
from cartridges.store.managers.sgdb_manager import SgdbManager
from cartridges.utils.create_dialog import create_dialog
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/preferences.ui")
class PreferencesWindow(Adw.PreferencesWindow):
__gtype_name__ = "PreferencesWindow"
general_page = Gtk.Template.Child()
import_page = Gtk.Template.Child()
sgdb_page = Gtk.Template.Child()
sources_group = Gtk.Template.Child()
exit_after_launch_switch = Gtk.Template.Child()
cover_launches_game_switch = Gtk.Template.Child()
high_quality_images_switch = Gtk.Template.Child()
remove_missing_switch = Gtk.Template.Child()
steam_expander_row = 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_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()
lutris_import_flatpak_switch = Gtk.Template.Child()
heroic_expander_row = 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_amazon_switch = Gtk.Template.Child()
heroic_import_sideload_switch = Gtk.Template.Child()
bottles_expander_row = 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_config_action_row = Gtk.Template.Child()
itch_config_file_chooser_button = Gtk.Template.Child()
legendary_expander_row = Gtk.Template.Child()
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()
flatpak_import_launchers_switch = Gtk.Template.Child()
desktop_switch = Gtk.Template.Child()
sgdb_key_group = Gtk.Template.Child()
sgdb_key_entry_row = Gtk.Template.Child()
sgdb_switch = Gtk.Template.Child()
sgdb_prefer_switch = Gtk.Template.Child()
sgdb_animated_switch = Gtk.Template.Child()
sgdb_fetch_button = 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[Game] = set()
warning_menu_buttons: dict = {}
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.file_chooser = Gtk.FileDialog()
self.set_transient_for(shared.win)
self.toast = Adw.Toast.new(_("All games removed"))
self.toast.set_button_label(_("Undo"))
self.toast.connect("button-clicked", self.undo_remove_all, None)
self.toast.set_priority(Adw.ToastPriority.HIGH)
(shortcut_controller := Gtk.ShortcutController()).add_shortcut(
Gtk.Shortcut.new(
Gtk.ShortcutTrigger.parse_string("<primary>z"),
Gtk.CallbackAction.new(self.undo_remove_all),
)
)
self.add_controller(shortcut_controller)
# General
self.remove_all_games_button.connect("clicked", self.remove_all_games)
# Debug
if shared.PROFILE == "development":
self.reset_action_row.set_visible(True)
self.reset_button.connect("clicked", self.reset_app)
# Sources settings
for source_class in (
BottlesSource,
FlatpakSource,
HeroicSource,
ItchSource,
LegendarySource,
LutrisSource,
RetroarchSource,
SteamSource,
):
source = source_class()
if not source.is_available:
expander_row = getattr(self, f"{source.source_id}_expander_row")
expander_row.set_visible(False)
else:
self.init_source_row(source)
# SteamGridDB
def sgdb_key_changed(*_args: Any) -> None:
shared.schema.set_string("sgdb-key", self.sgdb_key_entry_row.get_text())
self.sgdb_key_entry_row.set_text(shared.schema.get_string("sgdb-key"))
self.sgdb_key_entry_row.connect("changed", sgdb_key_changed)
self.sgdb_key_group.set_description(
_(
"An API key is required to use SteamGridDB. You can generate one {}here{}."
).format(
'<a href="https://www.steamgriddb.com/profile/preferences/api">', "</a>"
)
)
def update_sgdb(*_args: Any) -> None:
counter = 0
games_len = len(shared.store)
sgdb_manager = shared.store.managers[SgdbManager]
sgdb_manager.reset_cancellable()
self.add_toast(download_toast := Adw.Toast.new(_("Downloading covers…")))
def update_cover_callback(manager: SgdbManager) -> None:
nonlocal counter
nonlocal games_len
nonlocal download_toast
counter += 1
if counter != games_len:
return
for error in manager.collect_errors():
if isinstance(error, FriendlyError):
create_dialog(self, error.title, error.subtitle)
break
for game in shared.store:
game.update()
toast = Adw.Toast.new(_("Covers updated"))
toast.set_priority(Adw.ToastPriority.HIGH)
download_toast.dismiss()
self.add_toast(toast)
for game in shared.store:
sgdb_manager.process_game(game, {}, update_cover_callback)
self.sgdb_fetch_button.connect("clicked", update_sgdb)
# Switches
self.bind_switches(
{
"exit-after-launch",
"cover-launches-game",
"high-quality-images",
"remove-missing",
"lutris-import-steam",
"lutris-import-flatpak",
"heroic-import-epic",
"heroic-import-gog",
"heroic-import-amazon",
"heroic-import-sideload",
"flatpak-import-launchers",
"sgdb",
"sgdb-prefer",
"sgdb-animated",
"desktop",
}
)
def set_sgdb_sensitive(widget: Adw.EntryRow) -> None:
if not widget.get_text():
shared.schema.set_boolean("sgdb", False)
self.sgdb_switch.set_sensitive(widget.get_text())
self.sgdb_key_entry_row.connect("changed", set_sgdb_sensitive)
set_sgdb_sensitive(self.sgdb_key_entry_row)
def get_switch(self, setting: str) -> Any:
return getattr(self, f'{setting.replace("-", "_")}_switch')
def bind_switches(self, settings: set[str]) -> None:
for setting in settings:
shared.schema.bind(
setting,
self.get_switch(setting),
"active",
Gio.SettingsBindFlags.DEFAULT,
)
def choose_folder(
self, _widget: Any, callback: Callable, callback_data: Optional[str] = None
) -> None:
self.file_chooser.select_folder(shared.win, None, callback, callback_data)
def undo_remove_all(self, *_args: Any) -> None:
shared.win.get_application().state = shared.AppState.UNDO_REMOVE_ALL_GAMES
for game in self.removed_games:
game.removed = False
game.save()
game.update()
self.removed_games = set()
self.toast.dismiss()
shared.win.get_application().state = shared.AppState.DEFAULT
shared.win.create_source_rows()
def remove_all_games(self, *_args: Any) -> None:
shared.win.get_application().state = shared.AppState.REMOVE_ALL_GAMES
shared.win.row_selected(None, shared.win.all_games_row_box.get_parent())
for game in shared.store:
if not game.removed:
self.removed_games.add(game)
game.removed = True
game.save()
game.update()
if shared.win.navigation_view.get_visible_page() == shared.win.details_page:
shared.win.navigation_view.pop()
self.add_toast(self.toast)
shared.win.get_application().state = shared.AppState.DEFAULT
shared.win.create_source_rows()
def reset_app(self, *_args: Any) -> None:
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: Source) -> None:
"""Set the dir subtitle for a source's action rows"""
for location_name, location in source.locations._asdict().items():
# Get the action row to subtitle
action_row = getattr(
self, f"{source.source_id}_{location_name}_action_row", None
)
if not action_row:
continue
path = Path(shared.schema.get_string(location.schema_key)).expanduser()
# Remove the path prefix if picked via Flatpak portal
subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path))
action_row.set_subtitle(subtitle)
def resolve_locations(self, source: Source) -> None:
"""Resolve locations and add a warning if location cannot be found"""
for location_name, location in source.locations._asdict().items():
action_row = getattr(
self, f"{source.source_id}_{location_name}_action_row", None
)
if not action_row:
continue
try:
location.resolve()
except UnresolvableLocationError:
title = _("Installation Not Found")
description = _("Select a valid directory.")
format_start = '<span rise="12pt"><b><big>'
format_end = "</big></b></span>\n"
popover = Gtk.Popover(
focusable=True,
child=(
Gtk.Label(
label=format_start + title + format_end + description,
use_markup=True,
wrap=True,
max_width_chars=50,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
justify=Gtk.Justification.CENTER,
margin_top=9,
margin_bottom=9,
margin_start=12,
margin_end=12,
)
),
)
popover.update_property(
(Gtk.AccessibleProperty.LABEL,), (title + description,)
)
def set_a11y_label(widget: Gtk.Popover) -> None:
self.set_focus(widget)
popover.connect("show", set_a11y_label)
menu_button = Gtk.MenuButton(
icon_name="dialog-warning-symbolic",
valign=Gtk.Align.CENTER,
popover=popover,
tooltip_text=_("Warning"),
)
menu_button.add_css_class("warning")
action_row.add_prefix(menu_button)
self.warning_menu_buttons[source.source_id] = menu_button
def init_source_row(self, source: Source) -> None:
"""Initialize a preference row for a source class"""
def set_dir(_widget: Any, result: Gio.Task, location_name: str) -> None:
"""Callback called when a dir picker button is clicked"""
try:
path = Path(self.file_chooser.select_folder_finish(result).get_path())
except GLib.GError:
return
# Good picked location
location = source.locations._asdict()[location_name]
if location.check_candidate(path):
shared.schema.set_string(location.schema_key, str(path))
self.update_source_action_row_paths(source)
if self.warning_menu_buttons.get(source.source_id):
action_row = getattr(
self, f"{source.source_id}_{location_name}_action_row", None
)
action_row.remove( # type: ignore
self.warning_menu_buttons[source.source_id]
)
self.warning_menu_buttons.pop(source.source_id)
logging.debug("User-set value for %s is %s", location.schema_key, path)
# Bad picked location, inform user
else:
title = _("Invalid Directory")
dialog = create_dialog(
self,
title,
location.invalid_subtitle.format(source.name),
"choose_folder",
_("Set Location"),
)
def on_response(widget: Any, response: str) -> None:
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.source_id}_expander_row")
shared.schema.bind(
source.source_id,
expander_row,
"enable-expansion",
Gio.SettingsBindFlags.DEFAULT,
)
# Connect dir picker buttons
for location_name in source.locations._asdict():
button = getattr(
self, f"{source.source_id}_{location_name}_file_chooser_button", None
)
if button is not None:
button.connect("clicked", self.choose_folder, set_dir, location_name)
# Set the source row subtitles
self.resolve_locations(source)
self.update_source_action_row_paths(source)

74
cartridges/shared.py.in Normal file
View File

@@ -0,0 +1,74 @@
# shared.py.in
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from enum import IntEnum, auto
from pathlib import Path
from gi.repository import Gdk, Gio, GLib
class AppState(IntEnum):
DEFAULT = auto()
LOAD_FROM_DISK = auto()
IMPORT = auto()
REMOVE_ALL_GAMES = auto()
UNDO_REMOVE_ALL_GAMES = auto()
APP_ID = "@APP_ID@"
VERSION = "@VERSION@"
PREFIX = "@PREFIX@"
PROFILE = "@PROFILE@"
SPEC_VERSION = 1.5 # The version of the game_id.json spec
schema = Gio.Settings.new(APP_ID)
state_schema = Gio.Settings.new(APP_ID + ".State")
home = Path.home()
data_dir = Path(GLib.get_user_data_dir())
config_dir = Path(GLib.get_user_config_dir())
cache_dir = Path(GLib.get_user_cache_dir())
flatpak_dir = home / ".var" / "app"
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)")
try:
scale_factor = max(
monitor.get_scale_factor()
for monitor in Gdk.Display.get_default().get_monitors()
)
except AttributeError: # If shared.py is imported by the search provider
pass
else:
image_size = (200 * scale_factor, 300 * scale_factor)
# pylint: disable=invalid-name
win = None
importer = None
import_time = None
store = None
log_files = None

View File

@@ -0,0 +1,62 @@
# async_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any, Callable
from gi.repository import Gio
from cartridges.game import Game
from cartridges.store.managers.manager import Manager
class AsyncManager(Manager):
"""Manager that can run asynchronously"""
blocking = False
cancellable: Gio.Cancellable = None
def __init__(self) -> None:
super().__init__()
self.cancellable = Gio.Cancellable()
def cancel_tasks(self):
"""Cancel all tasks for this manager"""
self.cancellable.cancel()
def reset_cancellable(self):
"""Reset the cancellable for this manager.
Already scheduled Tasks will no longer be cancellable."""
self.cancellable = Gio.Cancellable()
def process_game(
self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any]
) -> None:
"""Create a task to process the game in a separate thread"""
task = Gio.Task.new(None, self.cancellable, self._task_callback, (callback,))
task.run_in_thread(lambda *_: self._task_thread_func((game, additional_data)))
def _task_thread_func(self, data):
"""Task thread entry point"""
game, additional_data, *_rest = data
self.run(game, additional_data)
def _task_callback(self, _source_object, _result, data):
"""Method run after the task is done"""
callback, *_rest = data
callback(self)

View File

@@ -0,0 +1,198 @@
# local_cover_manager.py
#
# Copyright 2023 Geoffrey Coulaud
# Copyright 2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from typing import NamedTuple
import requests
from gi.repository import GdkPixbuf, Gio
from requests.exceptions import HTTPError, SSLError
from cartridges import shared
from cartridges.game import Game
from cartridges.store.managers.manager import Manager
from cartridges.store.managers.steam_api_manager import SteamAPIManager
from cartridges.utils.save_cover import convert_cover, save_cover
class ImageSize(NamedTuple):
width: float = 0
height: float = 0
@property
def aspect_ratio(self) -> float:
return self.width / self.height
def __str__(self):
return f"{self.width}x{self.height}"
def __mul__(self, scale: float | int) -> "ImageSize":
return ImageSize(
self.width * scale,
self.height * scale,
)
def __truediv__(self, divisor: float | int) -> "ImageSize":
return self * (1 / divisor)
def __add__(self, other_size: "ImageSize") -> "ImageSize":
return ImageSize(
self.width + other_size.width,
self.height + other_size.height,
)
def __sub__(self, other_size: "ImageSize") -> "ImageSize":
return self + (other_size * -1)
def element_wise_div(self, other_size: "ImageSize") -> "ImageSize":
"""Divide every element of self by the equivalent in the other size"""
return ImageSize(
self.width / other_size.width,
self.height / other_size.height,
)
def element_wise_mul(self, other_size: "ImageSize") -> "ImageSize":
"""Multiply every element of self by the equivalent in the other size"""
return ImageSize(
self.width * other_size.width,
self.height * other_size.height,
)
def invert(self) -> "ImageSize":
"""Invert the element of self"""
return ImageSize(1, 1).element_wise_div(self)
class CoverManager(Manager):
"""
Manager in charge of adding the cover image of the game
Order of priority is:
1. local cover
2. icon cover
3. online cover
"""
run_after = (SteamAPIManager,)
retryable_on = (HTTPError, SSLError, ConnectionError)
def download_image(self, url: str) -> Path:
image_file = Gio.File.new_tmp()[0]
path = Path(image_file.get_path())
with requests.get(url, timeout=5) as cover:
cover.raise_for_status()
path.write_bytes(cover.content)
return path
def is_stretchable(self, source_size: ImageSize, cover_size: ImageSize) -> bool:
is_taller = source_size.aspect_ratio < cover_size.aspect_ratio
if is_taller:
return True
max_stretch = 0.12
resized_height = (1 / source_size.aspect_ratio) * cover_size.width
stretch = 1 - (resized_height / cover_size.height)
return stretch <= max_stretch
def composite_cover(
self,
image_path: Path,
scale: float = 1,
blur_size: ImageSize = ImageSize(2, 2),
) -> GdkPixbuf.Pixbuf:
"""
Return the image composited with a background blur.
If the image is stretchable, just stretch it.
:param path: Path where the source image is located
:param scale:
Scale of the smalled image side
compared to the corresponding side in the cover
:param blur_size: Size of the downscaled image used for the blur
"""
# Load source image
source = GdkPixbuf.Pixbuf.new_from_file(
str(convert_cover(image_path, resize=False))
)
source_size = ImageSize(source.get_width(), source.get_height())
cover_size = ImageSize._make(shared.image_size)
# Stretch if possible
if scale == 1 and self.is_stretchable(source_size, cover_size):
return source
# Create the blurred cover background
# fmt: off
cover = (
source
.scale_simple(*blur_size, GdkPixbuf.InterpType.BILINEAR)
.scale_simple(*cover_size, GdkPixbuf.InterpType.BILINEAR)
)
# fmt: on
# Scale to fit, apply scaling, then center
uniform_scale = scale * min(cover_size.element_wise_div(source_size))
source_in_cover_size = source_size * uniform_scale
source_in_cover_position = (cover_size - source_in_cover_size) / 2
# Center the scaled source image in the cover
source.composite(
cover,
*source_in_cover_position,
*source_in_cover_size,
*source_in_cover_position,
uniform_scale,
uniform_scale,
GdkPixbuf.InterpType.BILINEAR,
255,
)
return cover
def main(self, game: Game, additional_data: dict) -> None:
if game.blacklisted:
return
for key in (
"local_image_path",
"local_icon_path",
"online_cover_url",
):
# Get an image path
if not (value := additional_data.get(key)):
continue
if key == "online_cover_url":
image_path = self.download_image(value)
else:
image_path = Path(value)
if not image_path.is_file():
continue
# Icon cover
composite_kwargs = {}
if key == "local_icon_path":
composite_kwargs["scale"] = 0.7
composite_kwargs["blur_size"] = ImageSize(1, 2)
save_cover(
game.game_id,
convert_cover(
pixbuf=self.composite_cover(image_path, **composite_kwargs)
),
)

View File

@@ -0,0 +1,76 @@
# display_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from cartridges import shared
from cartridges.game import Game
from cartridges.game_cover import GameCover
from cartridges.store.managers.manager import Manager
from cartridges.store.managers.sgdb_manager import SgdbManager
from cartridges.store.managers.steam_api_manager import SteamAPIManager
class DisplayManager(Manager):
"""Manager in charge of adding a game to the UI"""
run_after = (SteamAPIManager, SgdbManager)
signals = {"update-ready"}
def main(self, game: Game, _additional_data: dict) -> None:
if game.get_parent():
game.get_parent().get_parent().remove(game)
if game.get_parent():
game.get_parent().set_child()
game.menu_button.set_menu_model(
game.hidden_game_options if game.hidden else game.game_options
)
game.title.set_label(game.name)
game.menu_button.get_popover().connect(
"notify::visible", game.toggle_play, None
)
game.menu_button.get_popover().connect(
"notify::visible", shared.win.set_active_game, game
)
if game.game_id in shared.win.game_covers:
game.game_cover = shared.win.game_covers[game.game_id]
game.game_cover.add_picture(game.cover)
else:
game.game_cover = GameCover({game.cover}, game.get_cover_path())
shared.win.game_covers[game.game_id] = game.game_cover
if (
shared.win.navigation_view.get_visible_page() == shared.win.details_page
and shared.win.active_game == game
):
shared.win.show_details_page(game)
if not game.removed and not game.blacklisted:
if game.hidden:
shared.win.hidden_library.append(game)
else:
shared.win.library.append(game)
game.get_parent().set_focusable(False)
shared.win.set_library_child()
if shared.win.get_application().state == shared.AppState.DEFAULT:
shared.win.create_source_rows()

View File

@@ -0,0 +1,59 @@
# file_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
from cartridges import shared
from cartridges.game import Game
from cartridges.store.managers.async_manager import AsyncManager
from cartridges.store.managers.steam_api_manager import SteamAPIManager
class FileManager(AsyncManager):
"""Manager in charge of saving a game to a file"""
run_after = (SteamAPIManager,)
signals = {"save-ready"}
def main(self, game: Game, additional_data: dict) -> None:
if additional_data.get("skip_save"): # Skip saving when loading games from disk
return
shared.games_dir.mkdir(parents=True, exist_ok=True)
attrs = (
"added",
"executable",
"game_id",
"source",
"hidden",
"last_played",
"name",
"developer",
"removed",
"blacklisted",
"version",
)
json.dump(
{attr: getattr(game, attr) for attr in attrs if attr},
(shared.games_dir / f"{game.game_id}.json").open("w"),
indent=4,
sort_keys=True,
)

View File

@@ -0,0 +1,120 @@
# manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from abc import abstractmethod
from time import sleep
from typing import Any, Callable, Container
from cartridges.errors.error_producer import ErrorProducer
from cartridges.errors.friendly_error import FriendlyError
from cartridges.game import Game
class Manager(ErrorProducer):
"""Class in charge of handling a post creation action for games.
* May connect to signals on the game to handle them.
* May cancel its running tasks on critical error,
in that case a new cancellable must be generated for new tasks to run.
* May be retried on some specific error types
"""
run_after: Container[type["Manager"]] = tuple()
blocking: bool = True
retryable_on: Container[type[Exception]] = tuple()
continue_on: Container[type[Exception]] = tuple()
signals: Container[type[str]] = set()
retry_delay: int = 3
max_tries: int = 3
@property
def name(self) -> str:
return type(self).__name__
@abstractmethod
def main(self, game: Game, additional_data: dict) -> None:
"""
Manager specific logic triggered by the run method
* Implemented by final child classes
* May block its thread
* May raise retryable exceptions that will trigger a retry if possible
* May raise other exceptions that will be reported
"""
def run(self, game: Game, additional_data: dict) -> None:
"""Handle errors (retry, ignore or raise) that occur in the manager logic"""
# Keep track of the number of tries
tries = 1
def handle_error(error: Exception) -> None:
nonlocal tries
# If FriendlyError, handle its cause instead
base_error = error
if isinstance(error, FriendlyError):
error = error.__cause__
log_args = (
type(error).__name__,
self.name,
f"{game.name} ({game.game_id})",
)
out_of_retries_format = "Out of retries dues to %s in %s for %s"
retrying_format = "Retrying %s in %s for %s"
unretryable_format = "Unretryable %s in %s for %s"
if type(error) in self.continue_on:
# Handle skippable errors (skip silently)
return
if type(error) in self.retryable_on:
if tries > self.max_tries:
# Handle being out of retries
logging.error(out_of_retries_format, *log_args)
self.report_error(base_error)
else:
# Handle retryable errors
logging.error(retrying_format, *log_args)
sleep(self.retry_delay)
tries += 1
try_manager_logic()
else:
# Handle unretryable errors
logging.error(unretryable_format, *log_args, exc_info=error)
self.report_error(base_error)
def try_manager_logic() -> None:
try:
self.main(game, additional_data)
except Exception as error: # pylint: disable=broad-exception-caught
handle_error(error)
try_manager_logic()
def process_game(
self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any]
) -> None:
"""Pass the game through the manager"""
self.run(game, additional_data)
callback(self)

View File

@@ -0,0 +1,48 @@
# sgdb_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from json import JSONDecodeError
from requests.exceptions import HTTPError, SSLError
from cartridges.errors.friendly_error import FriendlyError
from cartridges.game import Game
from cartridges.store.managers.async_manager import AsyncManager
from cartridges.store.managers.cover_manager import CoverManager
from cartridges.store.managers.steam_api_manager import SteamAPIManager
from cartridges.utils.steamgriddb import SgdbAuthError, SgdbHelper
class SgdbManager(AsyncManager):
"""Manager in charge of downloading a game's cover from SteamGridDB"""
run_after = (SteamAPIManager, CoverManager)
retryable_on = (HTTPError, SSLError, ConnectionError, JSONDecodeError)
def main(self, game: Game, _additional_data: dict) -> None:
try:
sgdb = SgdbHelper()
sgdb.conditionaly_update_cover(game)
except SgdbAuthError as error:
# If invalid auth, cancel all SGDBManager tasks
self.cancellable.cancel()
raise FriendlyError(
_("Couldn't Authenticate SteamGridDB"),
_("Verify your API key in preferences"),
) from error

View File

@@ -0,0 +1,57 @@
# steam_api_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from requests.exceptions import HTTPError, SSLError
from urllib3.exceptions import ConnectionError as Urllib3ConnectionError
from cartridges.game import Game
from cartridges.store.managers.async_manager import AsyncManager
from cartridges.utils.steam import (
SteamAPIHelper,
SteamGameNotFoundError,
SteamNotAGameError,
SteamRateLimiter,
)
class SteamAPIManager(AsyncManager):
"""Manager in charge of completing a game's data from the Steam API"""
retryable_on = (HTTPError, SSLError, Urllib3ConnectionError)
steam_api_helper: SteamAPIHelper = None
steam_rate_limiter: SteamRateLimiter = None
def __init__(self) -> None:
super().__init__()
self.steam_rate_limiter = SteamRateLimiter()
self.steam_api_helper = SteamAPIHelper(self.steam_rate_limiter)
def main(self, game: Game, additional_data: dict) -> None:
# Skip non-Steam games
appid = additional_data.get("steam_appid", None)
if appid is None:
return
# Get online metadata
try:
online_data = self.steam_api_helper.get_api_data(appid=appid)
except (SteamNotAGameError, SteamGameNotFoundError):
game.update_values({"blacklisted": True})
else:
game.update_values(online_data)

View File

@@ -0,0 +1,110 @@
# pipeline.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from typing import Iterable
from gi.repository import GObject
from cartridges.game import Game
from cartridges.store.managers.manager import Manager
class Pipeline(GObject.Object):
"""Class representing a set of managers for a game"""
game: Game
additional_data: dict
waiting: set[Manager]
running: set[Manager]
done: set[Manager]
def __init__(
self, game: Game, additional_data: dict, managers: Iterable[Manager]
) -> None:
super().__init__()
self.game = game
self.additional_data = additional_data
self.waiting = set(managers)
self.running = set()
self.done = set()
@property
def not_done(self) -> set[Manager]:
"""Get the managers that are not done yet"""
return self.waiting | self.running
@property
def is_done(self) -> bool:
return len(self.waiting) == 0 and len(self.running) == 0
@property
def blocked(self) -> set[Manager]:
"""Get the managers that cannot run because their dependencies aren't done"""
blocked = set()
for waiting in self.waiting:
for not_done in self.not_done:
if waiting == not_done:
continue
if type(not_done) in waiting.run_after:
blocked.add(waiting)
return blocked
@property
def ready(self) -> set[Manager]:
"""Get the managers that can be run"""
return self.waiting - self.blocked
@property
def progress(self) -> float:
"""Get the pipeline progress. Should only be a rough idea."""
n_done = len(self.done)
n_total = len(self.waiting) + len(self.running) + n_done
try:
progress = n_done / n_total
except ZeroDivisionError:
progress = 1
return progress
def advance(self) -> None:
"""Spawn tasks for managers that are able to run for a game"""
# Separate blocking / async managers
managers = self.ready
blocking = set(filter(lambda manager: manager.blocking, managers))
parallel = managers - blocking
# Schedule parallel managers, then run the blocking ones
for manager in (*parallel, *blocking):
self.waiting.remove(manager)
self.running.add(manager)
manager.process_game(self.game, self.additional_data, self.manager_callback)
def manager_callback(self, manager: Manager) -> None:
"""Method called by a manager when it's done"""
logging.debug("%s done for %s", manager.name, self.game.game_id)
self.running.remove(manager)
self.done.add(manager)
self.emit("advanced")
self.advance()
@GObject.Signal(name="advanced")
def advanced(self): # type: ignore
"""Signal emitted when the pipeline has advanced"""

163
cartridges/store/store.py Normal file
View File

@@ -0,0 +1,163 @@
# store.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from typing import Any, Generator, MutableMapping, Optional
from cartridges import shared
from cartridges.game import Game
from cartridges.store.managers.manager import Manager
from cartridges.store.pipeline import Pipeline
class Store:
"""Class in charge of handling games being added to the app."""
managers: dict[type[Manager], Manager]
pipeline_managers: set[Manager]
pipelines: dict[str, Pipeline]
source_games: MutableMapping[str, MutableMapping[str, Game]]
new_game_ids: set[str]
duplicate_game_ids: set[str]
def __init__(self) -> None:
self.managers = {}
self.pipeline_managers = set()
self.pipelines = {}
self.source_games = {}
self.new_game_ids = set()
self.duplicate_game_ids = set()
def __contains__(self, obj: object) -> bool:
"""Check if the game is present in the store with the `in` keyword"""
if not isinstance(obj, Game):
return False
if not (source_mapping := self.source_games.get(obj.base_source)):
return False
return obj.game_id in source_mapping
def __iter__(self) -> Generator[Game, None, None]:
"""Iterate through the games in the store with `for ... in`"""
for _source_id, games_mapping in self.source_games.items():
for _game_id, game in games_mapping.items():
yield game
def __len__(self) -> int:
"""Get the number of games in the store with the `len` builtin"""
return sum(len(source_mapping) for source_mapping in self.source_games.values())
def __getitem__(self, game_id: str) -> Game:
"""Get a game by its id with `store["game_id_goes_here"]`"""
for game in iter(self):
if game.game_id == game_id:
return game
raise KeyError("Game not found in store")
def get(self, game_id: str, default: Any = None) -> Game | Any:
"""Get a game by its ID, with a fallback if not found"""
try:
game = self[game_id]
return game
except KeyError:
return default
def add_manager(self, manager: Manager, in_pipeline: bool = True) -> None:
"""Add a manager to the store"""
manager_type = type(manager)
self.managers[manager_type] = manager
self.toggle_manager_in_pipelines(manager_type, in_pipeline)
def toggle_manager_in_pipelines(
self, manager_type: type[Manager], enable: bool
) -> None:
"""Change if a manager should run in new pipelines"""
if enable:
self.pipeline_managers.add(self.managers[manager_type])
else:
self.pipeline_managers.discard(self.managers[manager_type])
def cleanup_game(self, game: Game) -> None:
"""Remove a game's files, dismiss any loose toasts"""
for path in (
shared.games_dir / f"{game.game_id}.json",
shared.covers_dir / f"{game.game_id}.tiff",
shared.covers_dir / f"{game.game_id}.gif",
):
path.unlink(missing_ok=True)
# TODO: don't run this if the state is startup
for undo in ("remove", "hide"):
try:
shared.win.toasts[(game, undo)].dismiss()
shared.win.toasts.pop((game, undo))
except KeyError:
pass
def add_game(
self, game: Game, additional_data: dict, run_pipeline: bool = True
) -> Optional[Pipeline]:
"""Add a game to the app"""
# Ignore games from a newer spec version
if game.version > shared.SPEC_VERSION:
return None
# Scanned game is already removed, just clean it up
if game.removed:
self.cleanup_game(game)
return None
# Handle game duplicates
stored_game = self.get(game.game_id)
if not stored_game:
# New game, do as normal
logging.debug("New store game %s (%s)", game.name, game.game_id)
self.new_game_ids.add(game.game_id)
elif stored_game.removed:
# Will replace a removed game, cleanup its remains
logging.debug(
"New store game %s (%s) (replacing a removed one)",
game.name,
game.game_id,
)
self.cleanup_game(stored_game)
self.new_game_ids.add(game.game_id)
else:
# Duplicate game, ignore it
logging.debug("Duplicate store game %s (%s)", game.name, game.game_id)
self.duplicate_game_ids.add(game.game_id)
return None
# Connect signals
for manager in self.managers.values():
for signal in manager.signals:
game.connect(signal, manager.run)
# Add the game to the store
if not game.base_source in self.source_games:
self.source_games[game.base_source] = {}
self.source_games[game.base_source][game.game_id] = game
# Run the pipeline for the game
if not run_pipeline:
return None
pipeline = Pipeline(game, additional_data, self.pipeline_managers)
self.pipelines[game.game_id] = pipeline
pipeline.advance()
return pipeline

View File

@@ -0,0 +1,39 @@
# create_dialog.py
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Optional
from gi.repository import Adw, Gtk
def create_dialog(
win: Gtk.Window,
heading: str,
body: str,
extra_option: Optional[str] = None,
extra_label: Optional[str] = None,
) -> Adw.MessageDialog:
dialog = Adw.MessageDialog.new(win, heading, body)
dialog.add_response("dismiss", _("Dismiss"))
if extra_option:
dialog.add_response(extra_option, _(extra_label))
dialog.present()
return dialog

View File

@@ -0,0 +1,128 @@
# migrate_files_v1_to_v2.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
from pathlib import Path
from cartridges import shared
old_data_dir = shared.home / ".local" / "share"
old_cartridges_data_dir = old_data_dir / "cartridges"
migrated_file_path = old_cartridges_data_dir / ".migrated"
old_games_dir = old_cartridges_data_dir / "games"
old_covers_dir = old_cartridges_data_dir / "covers"
def migrate_game_covers(game_path: Path) -> None:
"""Migrate a game covers from a source game path to the current dir"""
for suffix in (".tiff", ".gif"):
cover_path = old_covers_dir / game_path.with_suffix(suffix).name
if not cover_path.is_file():
continue
destination_cover_path = shared.covers_dir / cover_path.name
logging.info("Moving %s -> %s", str(cover_path), str(destination_cover_path))
cover_path.rename(destination_cover_path)
def migrate_files_v1_to_v2() -> None:
"""
Migrate user data from the v1.X locations to the latest location.
Fix for commit 4a204442b5d8ba2e918f8c2605d72e483bf35efd
where the windows directories for data, config and cache changed.
"""
# Skip if there is no old dir
# Skip if old == current
# Skip if already migrated
if (
not old_data_dir.is_dir()
or str(old_data_dir) == str(shared.data_dir)
or migrated_file_path.is_file()
):
return
logging.info("Migrating data dir %s", str(old_data_dir))
# Create new directories
shared.games_dir.mkdir(parents=True, exist_ok=True)
shared.covers_dir.mkdir(parents=True, exist_ok=True)
old_game_paths = set(old_games_dir.glob("*.json"))
old_imported_game_paths = set(
filter(lambda path: path.name.startswith("imported_"), old_game_paths)
)
old_other_game_paths = old_game_paths - old_imported_game_paths
# Discover current imported games
imported_game_number = 0
imported_execs = set()
for game_path in shared.games_dir.glob("imported_*.json"):
try:
game_data = json.load(game_path.open("r"))
except (OSError, json.JSONDecodeError):
continue
number = int(game_data["game_id"].replace("imported_", ""))
imported_game_number = max(number, imported_game_number)
imported_execs.add(game_data["executable"])
# Migrate imported game files
for game_path in old_imported_game_paths:
try:
game_data = json.load(game_path.open("r"))
except (OSError, json.JSONDecodeError):
continue
# Don't migrate if there's a game with the same exec
if game_data["executable"] in imported_execs:
continue
# Migrate with updated index
imported_game_number += 1
game_id = f"imported_{imported_game_number}"
game_data["game_id"] = game_id
destination_game_path = shared.games_dir / f"{game_id}.json"
logging.info(
"Moving (updated id) %s -> %s", str(game_path), str(destination_game_path)
)
json.dump(
game_data,
destination_game_path.open("w"),
indent=4,
sort_keys=True,
)
game_path.unlink()
migrate_game_covers(game_path)
# Migrate all other games
for game_path in old_other_game_paths:
# Do nothing if already in games dir
destination_game_path = shared.games_dir / game_path.name
if destination_game_path.exists():
continue
# Else, migrate the game
logging.info("Moving %s -> %s", str(game_path), str(destination_game_path))
game_path.rename(destination_game_path)
migrate_game_covers(game_path)
# Signal that this dir is migrated
migrated_file_path.touch()
logging.info("Migration done")

View File

@@ -0,0 +1,221 @@
# rate_limiter.py
#
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from collections import deque
from contextlib import AbstractContextManager
from threading import BoundedSemaphore, Lock, Thread
from time import sleep, time
from typing import Any, Sized
class PickHistory(Sized):
"""Utility class used for rate limiters, counting how many picks
happened in a given period"""
period: int
timestamps: list[float]
timestamps_lock: Lock
def __init__(self, period: int) -> None:
self.period = period
self.timestamps = []
self.timestamps_lock = Lock()
def remove_old_entries(self) -> None:
"""Remove history entries older than the period"""
now = time()
cutoff = now - self.period
with self.timestamps_lock:
self.timestamps = [entry for entry in self.timestamps if entry > cutoff]
def add(self, *new_timestamps: float) -> None:
"""Add timestamps to the history.
If none given, will add the current timestamp"""
if len(new_timestamps) == 0:
new_timestamps = (time(),)
with self.timestamps_lock:
self.timestamps.extend(new_timestamps)
def __len__(self) -> int:
"""How many entries were logged in the period"""
self.remove_old_entries()
with self.timestamps_lock:
return len(self.timestamps)
@property
def start(self) -> float:
"""Get the time at which the history started"""
self.remove_old_entries()
with self.timestamps_lock:
try:
entry = self.timestamps[0]
except IndexError:
entry = time()
return entry
def copy_timestamps(self) -> list[float]:
"""Get a copy of the timestamps history"""
self.remove_old_entries()
with self.timestamps_lock:
return self.timestamps.copy()
# pylint: disable=too-many-instance-attributes
class RateLimiter(AbstractContextManager):
"""
Base rate limiter implementing the token bucket algorithm.
Do not use directly, create a child class to tailor the rate limiting to the
underlying service's limits.
Subclasses must provide values to the following attributes:
* refill_period_seconds - Period in which we have a max amount of tokens
* refill_period_tokens - Number of tokens allowed in this period
* burst_tokens - Max number of tokens that can be consumed instantly
"""
refill_period_seconds: int
refill_period_tokens: int
burst_tokens: int
pick_history: PickHistory
bucket: BoundedSemaphore
queue: deque[Lock]
queue_lock: Lock
# Protect the number of tokens behind a lock
__n_tokens_lock: Lock
__n_tokens = 0
@property
def n_tokens(self) -> int:
with self.__n_tokens_lock:
return self.__n_tokens
@n_tokens.setter
def n_tokens(self, value: int) -> None:
with self.__n_tokens_lock:
self.__n_tokens = value
def _init_pick_history(self) -> None:
"""
Initialize the tocken pick history
(only for use in this class and its children)
By default, creates an empty pick history.
Should be overriden or extended by subclasses.
"""
self.pick_history = PickHistory(self.refill_period_seconds)
def __init__(self) -> None:
"""Initialize the limiter"""
self._init_pick_history()
# Create synchronization data
self.__n_tokens_lock = Lock()
self.queue_lock = Lock()
self.queue = deque()
# Initialize the token bucket
self.bucket = BoundedSemaphore(self.burst_tokens)
self.n_tokens = self.burst_tokens
# Spawn daemon thread that refills the bucket
refill_thread = Thread(target=self.refill_thread_func, daemon=True)
refill_thread.start()
@property
def refill_spacing(self) -> float:
"""
Get the current refill spacing.
Ensures that even with a burst in the period, the limit will not be exceeded.
"""
# Compute ideal spacing
tokens_left = self.refill_period_tokens - len(self.pick_history) # type: ignore
seconds_left = self.pick_history.start + self.refill_period_seconds - time() # type: ignore
try:
spacing_seconds = seconds_left / tokens_left
except ZeroDivisionError:
# There were no remaining tokens, gotta wait until end of the period
spacing_seconds = seconds_left
# Prevent spacing dropping down lower than the natural spacing
natural_spacing = self.refill_period_seconds / self.refill_period_tokens
return max(natural_spacing, spacing_seconds)
def refill(self) -> None:
"""Add a token back in the bucket"""
sleep(self.refill_spacing)
try:
self.bucket.release()
except ValueError:
# Bucket was full
pass
else:
self.n_tokens += 1
def refill_thread_func(self) -> None:
"""Entry point for the daemon thread that is refilling the bucket"""
while True:
self.refill()
def update_queue(self) -> None:
"""Update the queue, moving it forward if possible. Non-blocking."""
update_thread = Thread(target=self.queue_update_thread_func, daemon=True)
update_thread.start()
def queue_update_thread_func(self) -> None:
"""Queue-updating thread's entry point"""
with self.queue_lock:
if len(self.queue) == 0:
return
# Not using with because we don't want to release to the bucket
self.bucket.acquire() # pylint: disable=consider-using-with
self.n_tokens -= 1
lock = self.queue.pop()
lock.release()
def add_to_queue(self) -> Lock:
"""Create a lock, add it to the queue and return it"""
lock = Lock()
# We want the lock locked until its turn in queue
lock.acquire() # pylint: disable=consider-using-with
with self.queue_lock:
self.queue.appendleft(lock)
return lock
def acquire(self) -> None:
"""Acquires a token from the bucket when it's your turn in queue"""
lock = self.add_to_queue()
self.update_queue()
# Wait until our turn in queue
lock.acquire() # pylint: disable=consider-using-with
self.pick_history.add() # type: ignore
# --- Support for use in with statements
def __enter__(self) -> None:
self.acquire()
def __exit__(self, *_args: Any) -> None:
pass

View File

@@ -0,0 +1,45 @@
# relative_date.py
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime
from typing import Any
from gi.repository import GLib
def relative_date(timestamp: int) -> Any: # pylint: disable=too-many-return-statements
days_no = ((today := datetime.today()) - datetime.fromtimestamp(timestamp)).days
if days_no == 0:
return _("Today")
if days_no == 1:
return _("Yesterday")
if days_no <= (day_of_week := today.weekday()):
return GLib.DateTime.new_from_unix_utc(timestamp).format("%A")
if days_no <= day_of_week + 7:
return _("Last Week")
if days_no <= (day_of_month := today.day):
return _("This Month")
if days_no <= day_of_month + 30:
return _("Last Month")
if days_no < (day_of_year := today.timetuple().tm_yday):
return GLib.DateTime.new_from_unix_utc(timestamp).format("%B")
if days_no <= day_of_year + 365:
return _("Last Year")
return GLib.DateTime.new_from_unix_utc(timestamp).format("%Y")

View File

@@ -0,0 +1,24 @@
import logging
import os
import subprocess
from shlex import quote
from cartridges import shared
def run_executable(executable) -> None:
args = (
"flatpak-spawn --host /bin/sh -c " + quote(executable) # Flatpak
if os.getenv("FLATPAK_ID") == shared.APP_ID
else executable # Others
)
logging.info("Launching `%s`", str(args))
# pylint: disable=consider-using-with
subprocess.Popen(
args,
cwd=shared.home,
shell=True,
start_new_session=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, # type: ignore
)

View File

@@ -0,0 +1,112 @@
# save_cover.py
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from shutil import copyfile
from typing import Optional
from gi.repository import Gdk, GdkPixbuf, Gio, GLib
from PIL import Image, ImageSequence, UnidentifiedImageError
from cartridges import shared
def convert_cover(
cover_path: Optional[Path] = None,
pixbuf: Optional[GdkPixbuf.Pixbuf] = None,
resize: bool = True,
) -> Optional[Path]:
if not cover_path and not pixbuf:
return None
pixbuf_extensions = set()
for pixbuf_format in GdkPixbuf.Pixbuf.get_formats():
for pixbuf_extension in pixbuf_format.get_extensions():
pixbuf_extensions.add(pixbuf_extension)
if not resize and cover_path and cover_path.suffix.lower()[1:] in pixbuf_extensions:
return cover_path
if pixbuf:
cover_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path())
pixbuf.savev(str(cover_path), "tiff", ["compression"], ["1"])
try:
with Image.open(cover_path) as image:
if getattr(image, "is_animated", False):
frames = tuple(
frame.resize((200, 300)) if resize else frame
for frame in ImageSequence.Iterator(image)
)
tmp_path = Path(Gio.File.new_tmp("XXXXXX.gif")[0].get_path())
frames[0].save(
tmp_path,
save_all=True,
append_images=frames[1:],
)
else:
# This might not be necessary in the future
# https://github.com/python-pillow/Pillow/issues/2663
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA")
tmp_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path())
(image.resize(shared.image_size) if resize else image).save(
tmp_path,
compression="tiff_adobe_deflate"
if shared.schema.get_boolean("high-quality-images")
else "webp",
)
except UnidentifiedImageError:
try:
Gdk.Texture.new_from_filename(str(cover_path)).save_to_tiff(
tmp_path := Gio.File.new_tmp("XXXXXX.tiff")[0].get_path()
)
return convert_cover(tmp_path)
except GLib.GError:
return None
return tmp_path
def save_cover(game_id: str, cover_path: Path) -> None:
shared.covers_dir.mkdir(parents=True, exist_ok=True)
animated_path = shared.covers_dir / f"{game_id}.gif"
static_path = shared.covers_dir / f"{game_id}.tiff"
# Remove previous covers
animated_path.unlink(missing_ok=True)
static_path.unlink(missing_ok=True)
if not cover_path:
return
copyfile(
cover_path,
animated_path if cover_path.suffix == ".gif" else static_path,
)
if game_id in shared.win.game_covers:
shared.win.game_covers[game_id].new_cover(
animated_path if cover_path.suffix == ".gif" else static_path
)

View File

@@ -0,0 +1,37 @@
# sqlite.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from glob import escape
from pathlib import Path
from shutil import copyfile
from gi.repository import GLib
def copy_db(original_path: Path) -> Path:
"""
Copy a sqlite database to a cache dir and return its new path.
The caller in in charge of deleting the returned path's parent dir.
"""
tmp = Path(GLib.Dir.make_tmp())
for file in original_path.parent.glob(f"{escape(original_path.name)}*"):
copy = tmp / file.name
copyfile(str(file), str(copy))
return tmp / original_path.name

158
cartridges/utils/steam.py Normal file
View File

@@ -0,0 +1,158 @@
# steam.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
import re
from pathlib import Path
from typing import TypedDict
import requests
from requests.exceptions import HTTPError
from cartridges import shared
from cartridges.utils.rate_limiter import RateLimiter
class SteamError(Exception):
pass
class SteamGameNotFoundError(SteamError):
pass
class SteamNotAGameError(SteamError):
pass
class SteamInvalidManifestError(SteamError):
pass
class SteamManifestData(TypedDict):
"""Dict returned by SteamFileHelper.get_manifest_data"""
name: str
appid: str
stateflags: str
class SteamAPIData(TypedDict):
"""Dict returned by SteamAPIHelper.get_api_data"""
developer: str
class SteamRateLimiter(RateLimiter):
"""Rate limiter for the Steam web API"""
# Steam web API limit
# 200 requests per 5 min seems to be the limit
# https://stackoverflow.com/questions/76047820/how-am-i-exceeding-steam-apis-rate-limit
# https://stackoverflow.com/questions/51795457/avoiding-error-429-too-many-requests-steam-web-api
refill_period_seconds = 5 * 60
refill_period_tokens = 200
burst_tokens = 100
def _init_pick_history(self) -> None:
"""
Load the pick history from schema.
Allows remembering API limits through restarts of Cartridges.
"""
super()._init_pick_history()
timestamps_str = shared.state_schema.get_string("steam-limiter-tokens-history")
self.pick_history.add(*json.loads(timestamps_str))
self.pick_history.remove_old_entries()
def acquire(self) -> None:
"""Get a token from the bucket and store the pick history in the schema"""
super().acquire()
timestamps_str = json.dumps(self.pick_history.copy_timestamps())
shared.state_schema.set_string("steam-limiter-tokens-history", timestamps_str)
class SteamFileHelper:
"""Helper for Steam file formats"""
def get_manifest_data(self, manifest_path: Path) -> SteamManifestData:
"""Get local data for a game from its manifest"""
with open(manifest_path, "r", encoding="utf-8") as file:
contents = file.read()
data = {}
for key in SteamManifestData.__required_keys__: # pylint: disable=no-member
regex = f'"{key}"\\s+"(.*)"\n'
if (match := re.search(regex, contents, re.IGNORECASE)) is None:
raise SteamInvalidManifestError()
data[key] = match.group(1)
return SteamManifestData(
name=data["name"],
appid=data["appid"],
stateflags=data["stateflags"],
)
class SteamAPIHelper:
"""Helper around the Steam API"""
base_url = "https://store.steampowered.com/api"
rate_limiter: RateLimiter
def __init__(self, rate_limiter: RateLimiter) -> None:
self.rate_limiter = rate_limiter
def get_api_data(self, appid: str) -> SteamAPIData:
"""
Get online data for a game from its appid.
May block to satisfy the Steam web API limitations.
See https://wiki.teamfortress.com/wiki/User:RJackson/StorefrontAPI#appdetails
"""
# Get data from the API (way block to satisfy its limits)
with self.rate_limiter:
try:
with requests.get(
f"{self.base_url}/appdetails?appids={appid}", timeout=5
) as response:
response.raise_for_status()
data = response.json()[appid]
except HTTPError as error:
logging.warning("Steam API HTTP error for %s", appid, exc_info=error)
raise error
# Handle not found
if not data["success"]:
logging.debug("Appid %s not found", appid)
raise SteamGameNotFoundError()
# Handle appid is not a game
if data["data"]["type"] not in {"game", "demo", "mod"}:
logging.debug("Appid %s is not a game", appid)
raise SteamNotAGameError()
# Return API values we're interested in
values = SteamAPIData(developer=", ".join(data["data"]["developers"]))
return values

View File

@@ -0,0 +1,159 @@
# steamgriddb.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from pathlib import Path
from typing import Any
import requests
from gi.repository import Gio
from requests.exceptions import HTTPError
from cartridges import shared
from cartridges.game import Game
from cartridges.utils.save_cover import convert_cover, save_cover
class SgdbError(Exception):
pass
class SgdbAuthError(SgdbError):
pass
class SgdbGameNotFound(SgdbError):
pass
class SgdbBadRequest(SgdbError):
pass
class SgdbNoImageFound(SgdbError):
pass
class SgdbHelper:
"""Helper class to make queries to SteamGridDB"""
base_url = "https://www.steamgriddb.com/api/v2/"
@property
def auth_headers(self) -> dict[str, str]:
key = shared.schema.get_string("sgdb-key")
headers = {"Authorization": f"Bearer {key}"}
return headers
def get_game_id(self, game: Game) -> Any:
"""Get grid results for a game. Can raise an exception."""
uri = f"{self.base_url}search/autocomplete/{game.name}"
res = requests.get(uri, headers=self.auth_headers, timeout=5)
match res.status_code:
case 200:
return res.json()["data"][0]["id"]
case 401:
raise SgdbAuthError(res.json()["errors"][0])
case 404:
raise SgdbGameNotFound(res.status_code)
case _:
res.raise_for_status()
def get_image_uri(self, game_id: str, animated: bool = False) -> Any:
"""Get the image for a SGDB game id"""
uri = f"{self.base_url}grids/game/{game_id}?dimensions=600x900"
if animated:
uri += "&types=animated"
res = requests.get(uri, headers=self.auth_headers, timeout=5)
match res.status_code:
case 200:
data = res.json()["data"]
if len(data) == 0:
raise SgdbNoImageFound()
return data[0]["url"]
case 401:
raise SgdbAuthError(res.json()["errors"][0])
case 404:
raise SgdbGameNotFound(res.status_code)
case _:
res.raise_for_status()
def conditionaly_update_cover(self, game: Game) -> None:
"""Update the game's cover if appropriate"""
# Obvious skips
use_sgdb = shared.schema.get_boolean("sgdb")
if not use_sgdb or game.blacklisted:
return
image_trunk = shared.covers_dir / game.game_id
still = image_trunk.with_suffix(".tiff")
animated = image_trunk.with_suffix(".gif")
prefer_sgdb = shared.schema.get_boolean("sgdb-prefer")
# Do nothing if file present and not prefer SGDB
if not prefer_sgdb and (still.is_file() or animated.is_file()):
return
# Get ID for the game
try:
sgdb_id = self.get_game_id(game)
except (HTTPError, SgdbError) as error:
logging.warning(
"%s while getting SGDB ID for %s", type(error).__name__, game.name
)
raise error
# Build different SGDB options to try
image_uri_kwargs_sets = [{"animated": False}]
if shared.schema.get_boolean("sgdb-animated"):
image_uri_kwargs_sets.insert(0, {"animated": True})
# Download covers
for uri_kwargs in image_uri_kwargs_sets:
try:
uri = self.get_image_uri(sgdb_id, **uri_kwargs)
response = requests.get(uri, timeout=5)
tmp_file = Gio.File.new_tmp()[0]
tmp_file_path = tmp_file.get_path()
Path(tmp_file_path).write_bytes(response.content)
save_cover(game.game_id, convert_cover(tmp_file_path))
except SgdbAuthError as error:
# Let caller handle auth errors
raise error
except (HTTPError, SgdbError) as error:
logging.warning(
"%s while getting image for %s kwargs=%s",
type(error).__name__,
game.name,
str(uri_kwargs),
)
continue
else:
# Stop as soon as one is finished
return
# No image was added
logging.warning(
'No matching image found for game "%s" (SGDB ID %d)',
game.name,
sgdb_id,
)
raise SgdbNoImageFound()

530
cartridges/window.py Normal file
View File

@@ -0,0 +1,530 @@
# window.py
#
# Copyright 2022-2023 kramo
#
# 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 <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any, Optional
from gi.repository import Adw, Gio, GLib, Gtk
from cartridges import shared
from cartridges.game import Game
from cartridges.game_cover import GameCover
from cartridges.utils.relative_date import relative_date
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/window.ui")
class CartridgesWindow(Adw.ApplicationWindow):
__gtype_name__ = "CartridgesWindow"
overlay_split_view = Gtk.Template.Child()
navigation_view = Gtk.Template.Child()
sidebar = Gtk.Template.Child()
all_games_row_box = Gtk.Template.Child()
all_games_no_label = Gtk.Template.Child()
added_row_box = Gtk.Template.Child()
added_games_no_label = Gtk.Template.Child()
toast_overlay = Gtk.Template.Child()
primary_menu_button = Gtk.Template.Child()
show_sidebar_button = Gtk.Template.Child()
details_view = Gtk.Template.Child()
library_page = Gtk.Template.Child()
library_view = Gtk.Template.Child()
library = Gtk.Template.Child()
scrolledwindow = Gtk.Template.Child()
library_overlay = Gtk.Template.Child()
notice_empty = Gtk.Template.Child()
notice_no_results = Gtk.Template.Child()
search_bar = Gtk.Template.Child()
search_entry = Gtk.Template.Child()
search_button = Gtk.Template.Child()
details_page = Gtk.Template.Child()
details_view_toolbar_view = Gtk.Template.Child()
details_view_cover = Gtk.Template.Child()
details_view_spinner = Gtk.Template.Child()
details_view_title = Gtk.Template.Child()
details_view_blurred_cover = Gtk.Template.Child()
details_view_play_button = Gtk.Template.Child()
details_view_developer = Gtk.Template.Child()
details_view_added = Gtk.Template.Child()
details_view_last_played = Gtk.Template.Child()
details_view_hide_button = Gtk.Template.Child()
hidden_library_page = Gtk.Template.Child()
hidden_primary_menu_button = Gtk.Template.Child()
hidden_library = Gtk.Template.Child()
hidden_library_view = Gtk.Template.Child()
hidden_scrolledwindow = Gtk.Template.Child()
hidden_library_overlay = Gtk.Template.Child()
hidden_notice_empty = Gtk.Template.Child()
hidden_notice_no_results = Gtk.Template.Child()
hidden_search_bar = Gtk.Template.Child()
hidden_search_entry = Gtk.Template.Child()
hidden_search_button = Gtk.Template.Child()
game_covers: dict = {}
toasts: dict = {}
active_game: Game
details_view_game_cover: Optional[GameCover] = None
sort_state: str = "a-z"
filter_state: str = "all"
source_rows: dict = {}
def create_source_rows(self) -> None:
def get_removed(source_id: str) -> Any:
removed = tuple(
game.removed or game.hidden or game.blacklisted
for game in shared.store.source_games[source_id].values()
)
return (
(count,) if (count := sum(removed)) != len(removed) else False
) # Return a tuple because 0 == False and 1 == True
total_games_no = 0
restored = False
selected_id = (
self.source_rows[selected_row][0]
if (selected_row := self.sidebar.get_selected_row()) in self.source_rows
else None
)
if selected_row == self.added_row_box.get_parent():
self.sidebar.select_row(self.added_row_box.get_parent())
restored = True
if added_missing := (
not shared.store.source_games.get("imported")
or not (removed := get_removed("imported"))
):
self.sidebar.select_row(self.all_games_row_box.get_parent())
else:
games_no = len(shared.store.source_games["imported"]) - removed[0]
self.added_games_no_label.set_label(str(games_no))
total_games_no += games_no
self.added_row_box.get_parent().set_visible(not added_missing)
self.sidebar.get_row_at_index(2).set_visible(False)
while row := self.sidebar.get_row_at_index(3):
self.sidebar.remove(row)
for source_id in shared.store.source_games:
if source_id == "imported":
continue
if not (removed := get_removed(source_id)):
continue
row = Gtk.Box(
margin_top=12,
margin_bottom=12,
margin_start=6,
margin_end=6,
spacing=12,
)
games_no = len(shared.store.source_games[source_id]) - removed[0]
total_games_no += games_no
row.append(
Gtk.Image.new_from_icon_name(
"user-desktop-symbolic"
if (split_id := source_id.split("_")[0]) == "desktop"
else f"{split_id}-source-symbolic"
)
)
row.append(
Gtk.Label(
label=self.get_application().get_source_name(source_id),
halign=Gtk.Align.START,
)
)
row.append(
games_no_label := Gtk.Label(
label=games_no,
hexpand=True,
halign=Gtk.Align.END,
)
)
games_no_label.add_css_class("dim-label")
# Order rows based on the number of games in them
index = 3
while source_row := self.sidebar.get_row_at_index(index):
if self.source_rows[source_row][1] < games_no:
self.sidebar.insert(row, index)
break
index += 1
if not row.get_parent():
self.sidebar.append(row)
self.source_rows[row.get_parent()] = (
source_id,
games_no,
)
if source_id == selected_id:
self.sidebar.select_row(row.get_parent())
restored = True
self.sidebar.get_row_at_index(2).set_visible(True)
self.all_games_no_label.set_label(str(total_games_no))
if not restored:
self.sidebar.select_row(self.all_games_row_box.get_parent())
def row_selected(self, _widget: Any, row: Gtk.ListBoxRow | None) -> None:
if not row:
return
match row.get_child():
case self.all_games_row_box:
value = "all"
case self.added_row_box:
value = "imported"
case _:
value = self.source_rows[row][0]
self.library_page.set_title(self.get_application().get_source_name(value))
self.filter_state = value
self.library.invalidate_filter()
if self.overlay_split_view.get_collapsed():
self.overlay_split_view.set_show_sidebar(False)
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.details_view.set_measure_overlay(self.details_view_toolbar_view, True)
self.details_view.set_clip_overlay(self.details_view_toolbar_view, False)
self.library.set_filter_func(self.filter_func)
self.hidden_library.set_filter_func(self.filter_func)
self.library.set_sort_func(self.sort_func)
self.hidden_library.set_sort_func(self.sort_func)
self.set_library_child()
self.notice_empty.set_icon_name(shared.APP_ID + "-symbolic")
self.overlay_split_view.set_show_sidebar(
shared.state_schema.get_boolean("show-sidebar")
)
self.sidebar.select_row(self.all_games_row_box.get_parent())
if shared.PROFILE == "development":
self.add_css_class("devel")
# Connect search entries
self.search_bar.connect_entry(self.search_entry)
self.hidden_search_bar.connect_entry(self.hidden_search_entry)
# Connect signals
self.search_entry.connect("search-changed", self.search_changed, False)
self.hidden_search_entry.connect("search-changed", self.search_changed, True)
self.search_entry.connect("activate", self.show_details_page_search)
self.hidden_search_entry.connect("activate", self.show_details_page_search)
self.navigation_view.connect("popped", self.set_show_hidden)
self.navigation_view.connect("pushed", self.set_show_hidden)
self.sidebar.connect("row-selected", self.row_selected)
style_manager = Adw.StyleManager.get_default()
style_manager.connect("notify::dark", self.set_details_view_opacity)
style_manager.connect("notify::high-contrast", self.set_details_view_opacity)
# Allow for a custom number of rows for the library
if shared.schema.get_uint("library-rows"):
shared.schema.bind(
"library-rows",
self.library,
"max-children-per-line",
Gio.SettingsBindFlags.DEFAULT,
)
shared.schema.bind(
"library-rows",
self.hidden_library,
"max-children-per-line",
Gio.SettingsBindFlags.DEFAULT,
)
def search_changed(self, _widget: Any, hidden: bool) -> None:
# Refresh search filter on keystroke in search box
(self.hidden_library if hidden else self.library).invalidate_filter()
def set_library_child(self) -> None:
child, hidden_child = self.notice_empty, self.hidden_notice_empty
for game in shared.store:
if game.removed or game.blacklisted:
continue
if game.hidden:
if game.filtered and hidden_child:
hidden_child = self.hidden_notice_no_results
continue
hidden_child = None
else:
if game.filtered and child:
child = self.notice_no_results
continue
child = None
def remove_from_overlay(widget: Gtk.Widget) -> None:
if isinstance(widget.get_parent(), Gtk.Overlay):
widget.get_parent().remove_overlay(widget)
if child:
self.library_overlay.add_overlay(child)
else:
remove_from_overlay(self.notice_empty)
remove_from_overlay(self.notice_no_results)
if hidden_child:
self.hidden_library_overlay.add_overlay(hidden_child)
else:
remove_from_overlay(self.hidden_notice_empty)
remove_from_overlay(self.hidden_notice_no_results)
def filter_func(self, child: Gtk.Widget) -> bool:
game = child.get_child()
text = (
(
self.hidden_search_entry
if self.navigation_view.get_visible_page() == self.hidden_library_page
else self.search_entry
)
.get_text()
.lower()
)
filtered = text != "" and not (
text in game.name.lower()
or (text in game.developer.lower() if game.developer else False)
)
if not filtered:
if self.filter_state == "all":
pass
elif game.base_source != self.filter_state:
filtered = True
game.filtered = filtered
self.set_library_child()
return not filtered
def set_active_game(self, _widget: Any, _pspec: Any, game: Game) -> None:
self.active_game = game
def show_details_page(self, game: Game) -> None:
self.active_game = game
self.details_view_cover.set_opacity(int(not game.loading))
self.details_view_spinner.set_spinning(game.loading)
self.details_view_developer.set_label(game.developer or "")
self.details_view_developer.set_visible(bool(game.developer))
icon, text = "view-conceal-symbolic", _("Hide")
if game.hidden:
icon, text = "view-reveal-symbolic", _("Unhide")
self.details_view_hide_button.set_icon_name(icon)
self.details_view_hide_button.set_tooltip_text(text)
if self.details_view_game_cover:
self.details_view_game_cover.pictures.remove(self.details_view_cover)
self.details_view_game_cover = game.game_cover
self.details_view_game_cover.add_picture(self.details_view_cover)
self.details_view_blurred_cover.set_paintable(
self.details_view_game_cover.get_blurred()
)
self.details_view_title.set_label(game.name)
self.details_page.set_title(game.name)
date = relative_date(game.added)
self.details_view_added.set_label(
# The variable is the date when the game was added
_("Added: {}").format(date)
)
last_played_date = (
relative_date(game.last_played) if game.last_played else _("Never")
)
self.details_view_last_played.set_label(
# The variable is the date when the game was last played
_("Last played: {}").format(last_played_date)
)
if self.navigation_view.get_visible_page() != self.details_page:
self.navigation_view.push(self.details_page)
self.set_focus(self.details_view_play_button)
self.set_details_view_opacity()
def set_details_view_opacity(self, *_args: Any) -> None:
if self.navigation_view.get_visible_page() != self.details_page:
return
if (
style_manager := Adw.StyleManager.get_default()
).get_high_contrast() or not style_manager.get_system_supports_color_schemes():
self.details_view_blurred_cover.set_opacity(0.3)
return
self.details_view_blurred_cover.set_opacity(
1 - self.details_view_game_cover.luminance[0] # type: ignore
if style_manager.get_dark()
else self.details_view_game_cover.luminance[1] # type: ignore
)
def sort_func(self, child1: Gtk.Widget, child2: Gtk.Widget) -> int:
var, order = "name", True
if self.sort_state in ("newest", "oldest"):
var, order = "added", self.sort_state == "newest"
elif self.sort_state == "last_played":
var = "last_played"
elif self.sort_state == "a-z":
order = False
def get_value(index: int) -> str:
return str(
getattr((child1.get_child(), child2.get_child())[index], var)
).lower()
if var != "name" and get_value(0) == get_value(1):
var, order = "name", False
return ((get_value(0) > get_value(1)) ^ order) * 2 - 1
def set_show_hidden(self, navigation_view: Adw.NavigationView, *_args: Any) -> None:
self.lookup_action("show_hidden").set_enabled(
navigation_view.get_visible_page() == self.library_page
)
def on_show_sidebar_action(self, *_args: Any) -> None:
shared.state_schema.set_boolean(
"show-sidebar", (value := not self.overlay_split_view.get_show_sidebar())
)
self.overlay_split_view.set_show_sidebar(value)
def on_go_to_parent_action(self, *_args: Any) -> None:
if self.navigation_view.get_visible_page() == self.details_page:
self.navigation_view.pop()
def on_go_home_action(self, *_args: Any) -> None:
self.navigation_view.pop_to_page(self.library_page)
def on_show_hidden_action(self, *_args: Any) -> None:
self.navigation_view.push(self.hidden_library_page)
def on_sort_action(self, action: Gio.SimpleAction, state: GLib.Variant) -> None:
action.set_state(state)
self.sort_state = str(state).strip("'")
self.library.invalidate_sort()
shared.state_schema.set_string("sort-mode", self.sort_state)
def on_toggle_search_action(self, *_args: Any) -> None:
if self.navigation_view.get_visible_page() == self.library_page:
search_bar = self.search_bar
search_entry = self.search_entry
elif self.navigation_view.get_visible_page() == self.hidden_library_page:
search_bar = self.hidden_search_bar
search_entry = self.hidden_search_entry
else:
return
search_bar.set_search_mode(not (search_mode := search_bar.get_search_mode()))
if not search_mode:
self.set_focus(search_entry)
search_entry.set_text("")
def on_escape_action(self, *_args: Any) -> None:
if (
self.get_focus() == self.search_entry.get_focus_child()
or self.hidden_search_entry.get_focus_child()
):
self.on_toggle_search_action()
else:
self.navigation_view.pop()
def show_details_page_search(self, widget: Gtk.Widget) -> None:
library = (
self.hidden_library if widget == self.hidden_search_entry else self.library
)
index = 0
while True:
if not (child := library.get_child_at_index(index)):
break
if self.filter_func(child):
self.show_details_page(child.get_child())
break
index += 1
def on_undo_action(
self, _widget: Any, game: Optional[Game] = None, undo: Optional[str] = None
) -> None:
if not game: # If the action was activated via Ctrl + Z
if shared.importer and (
shared.importer.imported_game_ids or shared.importer.removed_game_ids
):
shared.importer.undo_import()
return
try:
game = tuple(self.toasts.keys())[-1][0]
undo = tuple(self.toasts.keys())[-1][1]
except IndexError:
return
if game:
if undo == "hide":
game.toggle_hidden(False)
elif undo == "remove":
game.removed = False
game.save()
game.update()
self.toasts[(game, undo)].dismiss()
self.toasts.pop((game, undo))
def on_open_menu_action(self, *_args: Any) -> None:
if self.navigation_view.get_visible_page() == self.library_page:
self.primary_menu_button.popup()
elif self.navigation_view.get_visible_page() == self.hidden_library_page:
self.hidden_primary_menu_button.popup()
def on_close_action(self, *_args: Any) -> None:
self.close()