# 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()))