Files
cartridges/cartridges/games.py
2025-12-03 15:07:47 +01:00

164 lines
5.0 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Zoey Ahmed
# SPDX-FileCopyrightText: Copyright 2025 kramo
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
import json
import os
import subprocess
from collections.abc import Generator
from json import JSONDecodeError
from pathlib import Path
from shlex import quote
from types import UnionType
from typing import Any, NamedTuple, Self
from gi.repository import Gdk, Gio, GLib, GObject, Gtk
from cartridges import DATA_DIR
class _GameProp(NamedTuple):
name: str
type_: type | UnionType
required: bool = False
editable: bool = False
PROPERTIES: tuple[_GameProp, ...] = (
_GameProp("added", int),
_GameProp("executable", str | list[str], required=True, editable=True),
_GameProp("game_id", str, required=True),
_GameProp("source", str, required=True),
_GameProp("hidden", bool),
_GameProp("last_played", int),
_GameProp("name", str, required=True, editable=True),
_GameProp("developer", str, editable=True),
_GameProp("removed", bool),
_GameProp("blacklisted", bool),
_GameProp("version", float),
)
_GAMES_DIR = DATA_DIR / "games"
_COVERS_DIR = DATA_DIR / "covers"
_SPEC_VERSION = 2.0
class Game(Gio.SimpleActionGroup):
"""Game data class."""
__gtype_name__ = __qualname__
added = GObject.Property(type=int)
executable = GObject.Property(type=str)
game_id = GObject.Property(type=str)
source = GObject.Property(type=str)
hidden = GObject.Property(type=bool, default=False)
last_played = GObject.Property(type=int)
name = GObject.Property(type=str)
developer = GObject.Property(type=str)
removed = GObject.Property(type=bool, default=False)
blacklisted = GObject.Property(type=bool, default=False)
version = GObject.Property(type=float, default=_SPEC_VERSION)
cover = GObject.Property(type=Gdk.Texture)
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self.add_action_entries((
("play", lambda *_: self.play()),
("remove", lambda *_: setattr(self, "removed", True)),
))
self.add_action(unhide_action := Gio.SimpleAction.new("unhide"))
unhide_action.connect("activate", lambda *_: setattr(self, "hidden", False))
hidden = Gtk.PropertyExpression.new(Game, None, "hidden")
hidden.bind(unhide_action, "enabled", self)
self.add_action(hide_action := Gio.SimpleAction.new("hide"))
hide_action.connect("activate", lambda *_: setattr(self, "hidden", True))
not_hidden = Gtk.ClosureExpression.new(bool, lambda _, h: not h, (hidden,))
not_hidden.bind(hide_action, "enabled", self)
@classmethod
def from_data(cls, data: dict[str, Any]) -> Self:
"""Create a game from data. Useful for loading from JSON."""
game = cls()
for prop in PROPERTIES:
value = data.get(prop.name)
if not prop.required and value is None:
continue
if not isinstance(value, prop.type_):
raise TypeError
match prop.name:
case "executable" if isinstance(value, list):
value = " ".join(value)
case "version" if value and value > _SPEC_VERSION:
raise TypeError
case "version":
continue
setattr(game, prop.name, value)
return game
def play(self):
"""Run the executable command in a shell."""
if Path("/.flatpak-info").exists():
executable = f"flatpak-spawn --host /bin/sh -c {quote(self.executable)}"
else:
executable = self.executable
subprocess.Popen( # noqa: S602
executable,
cwd=Path.home(),
shell=True,
start_new_session=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0,
)
def save(self):
"""Save the game's properties to disk."""
properties = {prop.name: getattr(self, prop.name) for prop in PROPERTIES}
_GAMES_DIR.mkdir(parents=True, exist_ok=True)
path = (_GAMES_DIR / self.game_id).with_suffix(".json")
with path.open(encoding="utf-8") as f:
json.dump(properties, f, indent=4)
def _load() -> Generator[Game]:
for path in _GAMES_DIR.glob("*.json"):
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
except (JSONDecodeError, UnicodeDecodeError):
continue
try:
game = Game.from_data(data)
except TypeError:
continue
cover_path = _COVERS_DIR / game.game_id
for ext in ".gif", ".tiff":
filename = str(cover_path.with_suffix(ext))
try:
game.cover = Gdk.Texture.new_from_filename(filename)
except GLib.Error:
continue
else:
break
yield game
model = Gio.ListStore.new(Game)
model.splice(0, 0, tuple(_load()))