diff --git a/data/gtk/preferences.blp b/data/gtk/preferences.blp index 85dfa1e..5a87688 100644 --- a/data/gtk/preferences.blp +++ b/data/gtk/preferences.blp @@ -185,6 +185,21 @@ template PreferencesWindow : Adw.PreferencesWindow { } } } + + Adw.ExpanderRow itch_expander_row { + title: _("itch"); + show-enable-switch: true; + + Adw.ActionRow { + title: _("itch Install Location"); + subtitle: _("Directory to use when importing games"); + + Button itch_file_chooser_button { + icon-name: "folder-symbolic"; + valign: center; + } + } + } } } } diff --git a/data/hu.kramo.Cartridges.gschema.xml b/data/hu.kramo.Cartridges.gschema.xml index 108e336..d05ef97 100644 --- a/data/hu.kramo.Cartridges.gschema.xml +++ b/data/hu.kramo.Cartridges.gschema.xml @@ -51,6 +51,12 @@ "~/.var/app/com.usebottles.bottles/data/bottles/" + + + true + + + "~/.var/app/io.itch.itch/config/itch/" diff --git a/hu.kramo.Cartridges.json b/hu.kramo.Cartridges.json index 6dc752a..1182fcb 100644 --- a/hu.kramo.Cartridges.json +++ b/hu.kramo.Cartridges.json @@ -5,22 +5,23 @@ "sdk" : "org.gnome.Sdk", "command" : "cartridges", "finish-args" : [ + "--share=network", "--share=ipc", "--socket=fallback-x11", "--device=dri", "--socket=wayland", "--talk-name=org.freedesktop.Flatpak", - "--talk-name=org.gtk.vfs.*", - "--filesystem=xdg-run/gvfsd", "--filesystem=~/.steam/steam/:ro", "--filesystem=xdg-data/lutris/:ro", "--filesystem=xdg-cache/lutris/:ro", "--filesystem=xdg-config/heroic/:ro", "--filesystem=xdg-data/bottles/:ro", + "--filesystem=xdg-config/itch/:ro", "--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro", "--filesystem=~/.var/app/net.lutris.Lutris/:ro", "--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro", - "--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/:ro" + "--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/:ro", + "--filesystem=~/.var/app/io.itch.itch/config/itch/:ro" ], "cleanup" : [ "/include", diff --git a/src/main.py b/src/main.py index ad56e7f..6a92140 100644 --- a/src/main.py +++ b/src/main.py @@ -33,6 +33,7 @@ from .create_details_window import create_details_window from .get_games import get_games from .heroic_parser import heroic_parser from .importer import Importer +from .itch_parser import itch_parser from .lutris_parser import lutris_parser from .preferences import PreferencesWindow from .save_game import save_game @@ -206,6 +207,9 @@ class CartridgesApplication(Adw.Application): if self.win.schema.get_boolean("bottles"): bottles_parser(self.win) + if self.win.schema.get_boolean("itch"): + itch_parser(self.win) + self.win.importer.blocker = False if self.win.importer.import_dialog.is_visible and self.win.importer.queue == 0: diff --git a/src/meson.build b/src/meson.build index 5bbaae6..708174b 100644 --- a/src/meson.build +++ b/src/meson.build @@ -24,9 +24,10 @@ cartridges_sources = [ 'game.py', 'utils/importer.py', 'utils/steam_parser.py', + 'utils/lutris_parser.py', 'utils/heroic_parser.py', 'utils/bottles_parser.py', - 'utils/lutris_parser.py', + 'utils/itch_parser.py', 'utils/get_games.py', 'utils/save_game.py', 'utils/save_cover.py', diff --git a/src/preferences.py b/src/preferences.py index a2077fc..1c275aa 100644 --- a/src/preferences.py +++ b/src/preferences.py @@ -110,6 +110,9 @@ class PreferencesWindow(Adw.PreferencesWindow): bottles_expander_row = Gtk.Template.Child() bottles_file_chooser_button = Gtk.Template.Child() + itch_expander_row = Gtk.Template.Child() + itch_file_chooser_button = Gtk.Template.Child() + def __init__(self, parent_widget, **kwargs): super().__init__(**kwargs) self.schema = parent_widget.schema @@ -281,6 +284,18 @@ class PreferencesWindow(Adw.PreferencesWindow): if os.name == "nt": self.sources_group.remove(self.bottles_expander_row) + # itch + ImportPreferences( + self, + "itch", + "itch", + "itch-location", + [Path("db") / "butler.db"], + self.itch_expander_row, + self.itch_file_chooser_button, + True, + ) + def choose_folder(self, _widget, function): self.file_chooser.select_folder(self.parent_widget, None, function, None) diff --git a/src/utils/importer.py b/src/utils/importer.py index 894525d..951b06d 100644 --- a/src/utils/importer.py +++ b/src/utils/importer.py @@ -51,8 +51,8 @@ class Importer: self.import_dialog.present() - def save_cover(self, game_id, cover_path): - save_cover(self.parent_widget, game_id, cover_path) + def save_cover(self, game_id, cover_path=None, pixbuf=None): + save_cover(self.parent_widget, game_id, cover_path, pixbuf) def save_game(self, values=None): if values: diff --git a/src/utils/itch_parser.py b/src/utils/itch_parser.py new file mode 100644 index 0000000..c165b07 --- /dev/null +++ b/src/utils/itch_parser.py @@ -0,0 +1,161 @@ +# itch_parser.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 . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import urllib.request +from pathlib import Path +from shutil import copyfile +from sqlite3 import connect +from time import time + +from gi.repository import GdkPixbuf, Gio + + +def get_game(task, current_time, parent_widget, row, importer): + values = {} + + values["game_id"] = f"itch_{row[0]}" + + if ( + values["game_id"] in parent_widget.games + and not parent_widget.games[values["game_id"]].removed + ): + task.return_value(None) + return + + values["added"] = current_time + values["executable"] = ["xdg-open", f"itch://caves/{row[4]}/launch"] + values["hidden"] = False + values["last_played"] = 0 + values["name"] = row[1] + values["source"] = "itch" + + if row[3] or row[2]: + tmp_file = Gio.File.new_tmp(None)[0] + try: + with urllib.request.urlopen(row[3] or row[2], timeout=5) as open_file: + Path(tmp_file.get_path()).write_bytes(open_file.read()) + except urllib.error.URLError: + task.return_value(values) + return + + cover_pixbuf = GdkPixbuf.Pixbuf.new_from_stream_at_scale( + tmp_file.read(), 2, 2, False + ).scale_simple(400, 600, GdkPixbuf.InterpType.BILINEAR) + + itch_pixbuf = GdkPixbuf.Pixbuf.new_from_stream(tmp_file.read()) + itch_pixbuf = itch_pixbuf.scale_simple( + 400, + itch_pixbuf.get_height() * (400 / itch_pixbuf.get_width()), + GdkPixbuf.InterpType.BILINEAR, + ) + itch_pixbuf.composite( + cover_pixbuf, + 0, + (600 - itch_pixbuf.get_height()) / 2, + itch_pixbuf.get_width(), + itch_pixbuf.get_height(), + 0, + (600 - itch_pixbuf.get_height()) / 2, + 1.0, + 1.0, + GdkPixbuf.InterpType.BILINEAR, + 255, + ) + importer.save_cover(values["game_id"], pixbuf=cover_pixbuf) + task.return_value(values) + + +def get_games_async(parent_widget, rows, importer): + current_time = int(time()) + + # Wrap the function in another one as Gio.Task.run_in_thread does not allow for passing args + def create_func(current_time, parent_widget, row): + def wrapper(task, *_unused): + get_game( + task, + current_time, + parent_widget, + row, + importer, + ) + + return wrapper + + def update_games(_task, result): + final_values = result.propagate_value()[1] + # No need for an if statement as final_value would be None for games we don't want to save + importer.save_game(final_values) + + for row in rows: + task = Gio.Task.new(None, None, update_games) + task.run_in_thread(create_func(current_time, parent_widget, row)) + + +def itch_parser(parent_widget): + schema = parent_widget.schema + + database_path = ( + Path(schema.get_string("itch-location")) / "db" / "butler.db" + ).expanduser() + if not database_path.is_file(): + if Path("~/.var/app/io.itch.itch/config/itch/").expanduser().exists(): + schema.set_string("itch-location", "~/.var/app/io.itch.itch/config/itch/") + elif (parent_widget.config_dir / "itch").exists(): + schema.set_string("itch-location", str(parent_widget.config_dir / "itch")) + else: + return + + database_path = ( + Path(schema.get_string("itch-location")) / "db" / "butler.db" + ).expanduser() + + db_cache_dir = parent_widget.cache_dir / "cartridges" / "itch" + db_cache_dir.mkdir(parents=True, exist_ok=True) + + # Copy the file because sqlite3 doesn't like databases in /run/user/ + database_tmp_path = db_cache_dir / "butler.db" + copyfile(database_path, database_tmp_path) + + 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 + ; + """ + + connection = connect(database_tmp_path) + cursor = connection.execute(db_request) + rows = cursor.fetchall() + connection.close() + database_tmp_path.unlink(missing_ok=True) + + importer = parent_widget.importer + importer.total_queue += len(rows) + importer.queue += len(rows) + + get_games_async(parent_widget, rows, importer) diff --git a/src/utils/save_cover.py b/src/utils/save_cover.py index e6d75e8..22b3638 100644 --- a/src/utils/save_cover.py +++ b/src/utils/save_cover.py @@ -21,7 +21,7 @@ from gi.repository import GdkPixbuf, Gio -def save_cover(parent_widget, game_id, cover_path, pixbuf=None): +def save_cover(parent_widget, game_id, cover_path=None, pixbuf=None): covers_dir = parent_widget.data_dir / "cartridges" / "covers" covers_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/utils/steam_parser.py b/src/utils/steam_parser.py index 87aada9..f3bce0a 100644 --- a/src/utils/steam_parser.py +++ b/src/utils/steam_parser.py @@ -24,7 +24,7 @@ import urllib.request from pathlib import Path from time import time -from gi.repository import Gio, GLib +from gi.repository import Gio def update_values_from_data(content, values): @@ -70,25 +70,6 @@ def get_game( values["added"] = current_time values["last_played"] = 0 - url = f'https://store.steampowered.com/api/appdetails?appids={values["appid"]}' - - # On Linux the request is made through gvfs so the app can run without network permissions - if os.name == "nt": - try: - with urllib.request.urlopen(url, timeout=10) as open_file: - content = open_file.read().decode("utf-8") - except urllib.error.URLError: - content = None - else: - open_file = Gio.File.new_for_uri(url) - try: - content = open_file.load_contents()[1] - except GLib.GError: - content = None - - if content: - values = update_values_from_data(content, values) - if ( steam_dir / "appcache" @@ -105,8 +86,18 @@ def get_game( ), ) + try: + with urllib.request.urlopen( + f'https://store.steampowered.com/api/appdetails?appids={values["appid"]}', + timeout=5, + ) as open_file: + content = open_file.read().decode("utf-8") + except urllib.error.URLError: + task.return_value(values) + return + + values = update_values_from_data(content, values) task.return_value(values) - return def get_games_async(parent_widget, appmanifests, steam_dir, importer): @@ -129,19 +120,12 @@ def get_games_async(parent_widget, appmanifests, steam_dir, importer): return wrapper def update_games(_task, result): - try: - final_values = result.propagate_value()[1] - # No need for an if statement as final_value would be None for games we don't want to save - importer.save_game(final_values) - except GLib.GError: # Handle the exception for the timeout - importer.save_game() + final_values = result.propagate_value()[1] + # No need for an if statement as final_value would be None for games we don't want to save + importer.save_game(final_values) for appmanifest in appmanifests: - cancellable = Gio.Cancellable.new() - GLib.timeout_add_seconds(5, cancellable.cancel) - - task = Gio.Task.new(None, cancellable, update_games) - task.set_return_on_cancel(True) + task = Gio.Task.new(None, None, update_games) task.run_in_thread( create_func(datatypes, current_time, parent_widget, appmanifest, steam_dir) )